diff --git a/README.md b/README.md index 6e47a8c..a648d72 100644 --- a/README.md +++ b/README.md @@ -46,24 +46,25 @@ There are several permissions: * --allow-direct-tcpip * --allow-execute * --allow-sftp +* --allow-streamlocal-forward * --allow-tcpip-forward **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 +2023/08/11 11:40:44 INFO listening on :2222... +2023/08/11 11:40:44 INFO allowed: "tcpip-forward", "direct-tcpip", "execute", "sftp", "streamlocal-forward", "direct-streamlocal" +2023/08/11 11:40:44 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/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" +2023/08/11 11:41:03 INFO listening on :2222... +2023/08/11 11:41:03 INFO allowed: "direct-tcpip", "execute" +2023/08/11 11:41:03 INFO NOT allowed: "tcpip-forward", "sftp", "streamlocal-forward", "direct-streamlocal" ``` ## --help @@ -75,16 +76,17 @@ Usage: handy-sshd [flags] Flags: - --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 + --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-streamlocal-forward client can use Unix domain socket remote forwarding + --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 ``` diff --git a/cmd/root.go b/cmd/root.go index ae5d21b..a2d6b66 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -22,11 +22,12 @@ type flagType struct { sshShell string sshUsers []string - allowTcpipForward bool - allowDirectTcpip bool - allowExecute bool - allowSftp bool - allowDirectStreamlocal bool + allowTcpipForward bool + allowDirectTcpip bool + allowExecute bool + allowSftp bool + allowStreamlocalForward bool + allowDirectStreamlocal bool } type permissionFlagType = struct { @@ -50,6 +51,7 @@ func RootCmd() *cobra.Command { {name: "direct-tcpip", flagPtr: &flag.allowDirectTcpip}, {name: "execute", flagPtr: &flag.allowExecute}, {name: "sftp", flagPtr: &flag.allowSftp}, + {name: "streamlocal-forward", flagPtr: &flag.allowStreamlocalForward}, {name: "direct-streamlocal", flagPtr: &flag.allowDirectStreamlocal}, } rootCmd := cobra.Command{ @@ -76,6 +78,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.allowStreamlocalForward, "allow-streamlocal-forward", "", false, "client can use Unix domain socket remote forwarding") rootCmd.PersistentFlags().BoolVarP(&flag.allowDirectStreamlocal, "allow-direct-streamlocal", "", false, "client can use Unix domain socket local forwarding") return &rootCmd @@ -102,12 +105,13 @@ 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, - AllowDirectStreamlocal: flag.allowDirectStreamlocal, + Logger: logger, + AllowTcpipForward: flag.allowTcpipForward, + AllowDirectTcpip: flag.allowDirectTcpip, + AllowExecute: flag.allowExecute, + AllowSftp: flag.allowSftp, + AllowStreamlocalForward: flag.allowStreamlocalForward, + AllowDirectStreamlocal: flag.allowDirectStreamlocal, } var sshUsers []sshUser for _, u := range flag.sshUsers { diff --git a/cmd/root_test.go b/cmd/root_test.go index 5eeadf5..1960213 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) + assertUnixRemotePortForwarding(t, client) assertUnixLocalPortForwarding(t, client) } @@ -167,6 +168,7 @@ func TestAllowExecute(t *testing.T) { assertExec(t, client) assertPtyTerminal(t, client) assertNoSftp(t, client) + assertNoUnixRemotePortForwarding(t, client) assertNoUnixLocalPortForwarding(t, client) } @@ -197,6 +199,38 @@ func TestAllowTcpipForward(t *testing.T) { assertNoExec(t, client) assertNoPtyTerminal(t, client) assertNoSftp(t, client) + assertNoUnixRemotePortForwarding(t, client) + assertNoUnixLocalPortForwarding(t, client) +} + +func TestAllowStreamlocalForward(t *testing.T) { + rootCmd := RootCmd() + port := getAvailableTcpPort() + rootCmd.SetArgs([]string{"--port", strconv.Itoa(port), "--user", "john:mypassword", "--allow-streamlocal-forward"}) + 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) + assertUnixRemotePortForwarding(t, client) assertNoUnixLocalPortForwarding(t, client) } @@ -227,6 +261,7 @@ func TestAllowDirectTcpip(t *testing.T) { assertNoExec(t, client) assertNoPtyTerminal(t, client) assertNoSftp(t, client) + assertNoUnixRemotePortForwarding(t, client) assertNoUnixLocalPortForwarding(t, client) } @@ -257,6 +292,7 @@ func TestAllowDirectStreamlocal(t *testing.T) { assertNoExec(t, client) assertNoPtyTerminal(t, client) assertNoSftp(t, client) + assertNoUnixRemotePortForwarding(t, client) assertUnixLocalPortForwarding(t, client) } @@ -286,5 +322,6 @@ func TestAllowSftp(t *testing.T) { assertNoLocalPortForwarding(t, client) assertNoExec(t, client) assertNoPtyTerminal(t, client) + assertNoUnixRemotePortForwarding(t, client) assertSftp(t, client) } diff --git a/cmd/test_util_test.go b/cmd/test_util_test.go index b870aaa..9d7e5ab 100644 --- a/cmd/test_util_test.go +++ b/cmd/test_util_test.go @@ -187,6 +187,7 @@ func assertRemotePortForwarding(t *testing.T, client *ssh.Client) { remotePort := getAvailableTcpPort() ln, err := client.Listen("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(remotePort))) assert.NoError(t, err) + defer ln.Close() acceptedConnChan := make(chan net.Conn) go func() { conn, err := ln.Accept() @@ -224,6 +225,50 @@ func assertNoRemotePortForwarding(t *testing.T, client *ssh.Client) { assert.Equal(t, "ssh: tcpip-forward request denied by peer", err.Error()) } +func assertUnixRemotePortForwarding(t *testing.T, client *ssh.Client) { + remoteUnixSocket := path.Join(os.TempDir(), "test-unix-socket-"+uuid.New().String()) + ln, err := client.ListenUnix(remoteUnixSocket) + assert.NoError(t, err) + defer os.Remove(remoteUnixSocket) + defer ln.Close() + acceptedConnChan := make(chan net.Conn) + go func() { + conn, err := ln.Accept() + assert.NoError(t, err) + acceptedConnChan <- conn + }() + conn, err := net.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 assertNoUnixRemotePortForwarding(t *testing.T, client *ssh.Client) { + remoteUnixSocket := path.Join(os.TempDir(), "test-unix-socket-"+uuid.New().String()) + _, err := client.ListenUnix(remoteUnixSocket) + assert.Error(t, err) + assert.Equal(t, "ssh: streamlocal-forward@openssh.com request denied by peer", err.Error()) +} + func assertSftp(t *testing.T, client *ssh.Client) { sftpClient, err := sftp.NewClient(client) assert.NoError(t, err) diff --git a/server.go b/server.go index a6a0162..7e1dedd 100644 --- a/server.go +++ b/server.go @@ -30,11 +30,12 @@ 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 - AllowDirectStreamlocal 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 + AllowStreamlocalForward bool + AllowDirectStreamlocal bool // TODO: DNS server ? } @@ -121,6 +122,8 @@ func (s *Server) handleSession(shell string, newChannel ssh.NewChannel) { } case "subsystem": s.handleSessionSubSystem(req, connection) + default: + s.Logger.Info("unknown request", "req_type", req.Type) } } } @@ -311,8 +314,20 @@ func (s *Server) HandleGlobalRequests(sshConn *ssh.ServerConn, reqs <-chan *ssh. req.Reply(false, nil) break } - s.handleTcpipForward(sshConn, req) - // TODO: support: streamlocal-forward@openssh.com https://github.com/golang/crypto/blob/master/ssh/streamlocal.go + go func() { + s.handleTcpipForward(sshConn, req) + }() + case "streamlocal-forward@openssh.com": + if !s.AllowStreamlocalForward { + s.Logger.Info("streamlocal-forward not allowed") + req.Reply(false, nil) + break + } + go func() { + s.handleStreamlocalForward(sshConn, req) + }() + // TODO: support cancel-tcpip-forward + // TODO: support cancel-streamlocal-forward@openssh.com default: // discard if req.WantReply { @@ -347,6 +362,7 @@ func (s *Server) handleTcpipForward(sshConn *ssh.ServerConn, req *ssh.Request) { for { conn, err := ln.Accept() if err != nil { + s.Logger.Info("failed to accept", "err", err) return } var replyMsg struct { @@ -387,3 +403,59 @@ func (s *Server) handleTcpipForward(sshConn *ssh.ServerConn, req *ssh.Request) { }() } } + +// client side: https://github.com/golang/crypto/blob/b4ddeeda5bc71549846db71ba23e83ecb26f36ed/ssh/streamlocal.go#L34 +func (s *Server) handleStreamlocalForward(sshConn *ssh.ServerConn, req *ssh.Request) { + // https://github.com/openssh/openssh-portable/blob/f9f18006678d2eac8b0c5a5dddf17ab7c50d1e9f/PROTOCOL#L272 + var msg struct { + SocketPath string + } + if err := ssh.Unmarshal(req.Payload, &msg); err != nil { + req.Reply(false, nil) + return + } + ln, err := net.Listen("unix", msg.SocketPath) + if err != nil { + req.Reply(false, nil) + return + } + req.Reply(true, nil) + go func() { + sshConn.Wait() + ln.Close() + s.Logger.Info("connection closed", "address", ln.Addr().String()) + }() + for { + conn, err := ln.Accept() + if err != nil { + s.Logger.Info("failed to accept", "err", err) + return + } + // https://github.com/openssh/openssh-portable/blob/f9f18006678d2eac8b0c5a5dddf17ab7c50d1e9f/PROTOCOL#L255 + var replyMsg struct { + SocketPath string + Reserved string + } + replyMsg.SocketPath = msg.SocketPath + + go func() { + channel, reqs, err := sshConn.OpenChannel("forwarded-streamlocal@openssh.com", ssh.Marshal(&replyMsg)) + if err != nil { + req.Reply(false, nil) + conn.Close() + return + } + go ssh.DiscardRequests(reqs) + go func() { + io.Copy(channel, conn) + conn.Close() + channel.Close() + }() + go func() { + io.Copy(conn, channel) + conn.Close() + channel.Close() + }() + }() + } +}