diff --git a/cmd/tunnel.go b/cmd/tunnel.go index 8b0c22c..d853b90 100644 --- a/cmd/tunnel.go +++ b/cmd/tunnel.go @@ -7,6 +7,8 @@ import ( "os/signal" "syscall" + "github.com/cloudradar-monitoring/rportcli/internal/pkg/rdp" + "github.com/cloudradar-monitoring/rportcli/internal/pkg/cache" "github.com/cloudradar-monitoring/rportcli/internal/pkg/client" @@ -174,6 +176,39 @@ Any parameter passed are append to the ssh command. i.e. -b "-l root"`, ShortName: "b", Type: config.StringRequirementType, }, + { + Field: controllers.LaunchRDP, + Description: `Start the default RDP client after the tunnel is established. +Optionally pass the rdp-width and rdp-height params of the session.`, + ShortName: "d", + Type: config.BoolRequirementType, + Default: "0", + }, + { + Field: controllers.RDPWidth, + Description: `RDP window width, 1024 is the default`, + ShortName: "w", + Type: config.StringRequirementType, + Default: "1024", + }, + { + Field: controllers.RDPHeight, + Description: `RDP window height, 768 is the default`, + ShortName: "i", + Type: config.StringRequirementType, + Default: "768", + }, + { + Field: controllers.RDPUser, + Description: `[required] username for a RDP session`, + ShortName: "u", + Type: config.StringRequirementType, + Validate: config.RequiredValidate, + Help: "Enter a RDP user name", + IsEnabled: func(providedParams *options.ParameterBag) bool { + return IsRDPUserRequired + }, + }, } } @@ -220,12 +255,21 @@ func createTunnelController(params *options.ParameterBag) *controllers.TunnelCon Cache: &cache.ClientsCache{}, } + rdpExecutor := &rdp.Executor{ + CommandProvider: rdp.CommandProvider, + StdOut: os.Stdout, + Stdin: os.Stdin, + StdErr: os.Stderr, + } + return &controllers.TunnelController{ Rport: rportAPI, TunnelRenderer: tr, IPProvider: rportAPI, ClientSearch: clientSearch, SSHFunc: utils.RunSSH, + RDPWriter: rdp.WriteRdpFile, + RDPExecutor: rdpExecutor, } } diff --git a/cmd/tunnelRDP.go b/cmd/tunnelRDP.go new file mode 100644 index 0000000..57a18b5 --- /dev/null +++ b/cmd/tunnelRDP.go @@ -0,0 +1,3 @@ +package cmd + +var IsRDPUserRequired = false diff --git a/cmd/tunnelRDP_linux.go b/cmd/tunnelRDP_linux.go new file mode 100644 index 0000000..38770ec --- /dev/null +++ b/cmd/tunnelRDP_linux.go @@ -0,0 +1,4 @@ +// +build linux +package cmd + +var IsRDPUserRequired = true diff --git a/internal/pkg/controllers/tunnel.go b/internal/pkg/controllers/tunnel.go index 98d693f..7843a78 100644 --- a/internal/pkg/controllers/tunnel.go +++ b/internal/pkg/controllers/tunnel.go @@ -4,10 +4,17 @@ import ( "context" "errors" "fmt" + "io" + "io/ioutil" "net/url" + "os" + "path/filepath" "strconv" "strings" + io2 "github.com/breathbath/go_utils/v2/pkg/io" + "github.com/cloudradar-monitoring/rportcli/internal/pkg/rdp" + "github.com/cloudradar-monitoring/rportcli/internal/pkg/config" "github.com/cloudradar-monitoring/rportcli/internal/pkg/output" @@ -30,6 +37,10 @@ const ( ACL = "acl" CheckPort = "checkp" LaunchSSH = "launch-ssh" + LaunchRDP = "launch-rdp" + RDPWidth = "rdp-width" + RDPHeight = "rdp-height" + RDPUser = "rdp-user" DefaultACL = "<>" ) @@ -49,6 +60,8 @@ type TunnelController struct { IPProvider IPProvider ClientSearch ClientSearch SSHFunc func(sshParams []string) error + RDPWriter func(fi rdp.FileInput, w io.Writer) error + RDPExecutor *rdp.Executor } func (tc *TunnelController) Tunnels(ctx context.Context) error { @@ -159,12 +172,15 @@ func (tc *TunnelController) Create(ctx context.Context, params *options.Paramete return err } - shouldLaunchSSH := params.ReadString(LaunchSSH, "") - if shouldLaunchSSH == "" { - return nil + if params.ReadString(LaunchSSH, "") != "" { + return tc.startSSHFlow(ctx, tunnelCreated, params, clientID) + } + + if params.ReadString(LaunchRDP, "") != "" { + return tc.startRDPFlow(tunnelCreated, params) } - return tc.startSSHFlow(ctx, tunnelCreated, params, clientID) + return nil } func (tc *TunnelController) startSSHFlow( @@ -175,7 +191,7 @@ func (tc *TunnelController) startSSHFlow( ) error { sshParamsFlat := params.ReadString(LaunchSSH, "") logrus.Debugf("ssh arguments are provided: '%s', will start an ssh session", sshParamsFlat) - port, host, err := tc.getSSHPortAndHost(tunnelCreated, params) + port, host, err := tc.extractPortAndHost(tunnelCreated, params) if err != nil { return fmt.Errorf("failed to parse rport URL '%s': %v", params.ReadString(config.ServerURL, ""), err) } @@ -212,7 +228,7 @@ func (tc *TunnelController) startSSHFlow( } func (tc *TunnelController) generateUsage(tunnelCreated *models.TunnelCreated, params *options.ParameterBag) string { - port, host, err := tc.getSSHPortAndHost(tunnelCreated, params) + port, host, err := tc.extractPortAndHost(tunnelCreated, params) if err != nil { logrus.Error(err) return "" @@ -229,7 +245,7 @@ func (tc *TunnelController) generateUsage(tunnelCreated *models.TunnelCreated, p return fmt.Sprintf("ssh %s -l ${USER}", host) } -func (tc *TunnelController) getSSHPortAndHost( +func (tc *TunnelController) extractPortAndHost( tunnelCreated *models.TunnelCreated, params *options.ParameterBag, ) (port, host string, err error) { @@ -268,3 +284,35 @@ func (tc *TunnelController) findClientID(ctx context.Context, clientName string, } return clients[0].ID, nil } + +func (tc *TunnelController) startRDPFlow( + tunnelCreated *models.TunnelCreated, + params *options.ParameterBag, +) error { + port, host, err := tc.extractPortAndHost(tunnelCreated, params) + if err != nil { + return err + } + + rdpFileInput := rdp.FileInput{ + Address: fmt.Sprintf("%s:%s", host, port), + ScreenHeight: params.ReadInt(RDPHeight, 0), + ScreenWidth: params.ReadInt(RDPWidth, 0), + UserName: params.ReadString(RDPUser, ""), + } + file, err := ioutil.TempFile("", "rport-*.rdp") + if err != nil { + return err + } + defer io2.CloseResourceSecure("temp file", file) + + logrus.Debugf("will write an rdp file %s", file.Name()) + err = tc.RDPWriter(rdpFileInput, file) + if err != nil { + return err + } + + rdpFileLocation := filepath.Join(os.TempDir(), file.Name()) + logrus.Debugf("written rdp file to %s", rdpFileLocation) + return tc.RDPExecutor.StartRdp(rdpFileLocation) +} diff --git a/internal/pkg/controllers/tunnel_test.go b/internal/pkg/controllers/tunnel_test.go index 0e8d96e..94de8c3 100644 --- a/internal/pkg/controllers/tunnel_test.go +++ b/internal/pkg/controllers/tunnel_test.go @@ -10,6 +10,8 @@ import ( "net/http/httptest" "testing" + "github.com/cloudradar-monitoring/rportcli/internal/pkg/rdp" + options "github.com/breathbath/go_utils/v2/pkg/config" "github.com/cloudradar-monitoring/rportcli/internal/pkg/output" @@ -654,3 +656,79 @@ func TestTunnelCreateWithSSHFailure(t *testing.T) { err := tController.Create(context.Background(), params) assert.EqualError(t, err, "ssh failure") } + +func TestTunnelCreateWithRDP(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + jsonEnc := json.NewEncoder(rw) + e := jsonEnc.Encode(api.TunnelCreatedResponse{Data: &models.TunnelCreated{ + ID: "777", + Lhost: "lohost77", + ClientID: "1314", + Lport: "3344", + Scheme: "ssh", + }}) + assert.NoError(t, e) + })) + defer srv.Close() + + apiAuth := &utils.StorageBasicAuth{ + AuthProvider: func() (login, pass string, err error) { return "dfasf", "34123", nil }, + } + + renderBuf := bytes.Buffer{} + cmdOutput := bytes.Buffer{} + + cl := api.New(srv.URL, apiAuth) + + isRDPCalled := false + tController := TunnelController{ + Rport: cl, + TunnelRenderer: &TunnelRendererMock{Writer: &renderBuf}, + IPProvider: IPProviderMock{ + IP: "3.4.5.166", + }, + ClientSearch: &ClientSearchMock{clientsToGive: []models.Client{}}, + RDPWriter: func(fi rdp.FileInput, w io.Writer) error { + isRDPCalled = true + assert.Equal( + t, + rdp.FileInput{ + Address: "rport-url123.com:3344", + ScreenHeight: 990, + ScreenWidth: 1090, + UserName: "Administrator", + }, + fi, + ) + return nil + }, + RDPExecutor: &rdp.Executor{ + CommandProvider: func(filePath string) (cmd string, args []string) { + assert.Contains(t, filePath, ".rdp") + return "echo", []string{"rdp executed"} + }, + StdOut: &cmdOutput, + }, + } + + params := config.FromValues(map[string]string{ + ClientID: "1315", + Local: "lohost88:3304", + Scheme: "rdp", + config.ServerURL: "http://rport-url123.com", + LaunchRDP: "1", + RDPUser: "Administrator", + RDPWidth: "1090", + RDPHeight: "990", + }) + err := tController.Create(context.Background(), params) + assert.NoError(t, err) + assert.Equal( + t, + `rdp executed +`, + cmdOutput.String(), + ) + + assert.True(t, isRDPCalled) +} diff --git a/internal/pkg/rdp/exec.go b/internal/pkg/rdp/exec.go new file mode 100644 index 0000000..ece98bc --- /dev/null +++ b/internal/pkg/rdp/exec.go @@ -0,0 +1,33 @@ +package rdp + +import ( + "io" + "os/exec" + + "github.com/sirupsen/logrus" +) + +type Executor struct { + CommandProvider func(filePath string) (cmd string, args []string) + StdOut io.Writer + Stdin io.Reader + StdErr io.Writer +} + +func (re *Executor) StartRdp(filePath string) error { + rdpCmd, args := re.CommandProvider(filePath) + c := exec.Command(rdpCmd, args...) + + c.Stdout = re.StdOut + c.Stdin = re.Stdin + c.Stderr = re.StdErr + + err := c.Run() + logrus.Debugf("will run %s", c.String()) + if err != nil { + return err + } + logrus.Debugf("finished run %s", c.String()) + + return nil +} diff --git a/internal/pkg/rdp/exec_nix.go b/internal/pkg/rdp/exec_nix.go new file mode 100644 index 0000000..a6fd8bd --- /dev/null +++ b/internal/pkg/rdp/exec_nix.go @@ -0,0 +1,7 @@ +// +build linux + +package rdp + +func CommandProvider(filePath string) (cmd string, args []string) { + return "xfreerdp", []string{filePath} +} diff --git a/internal/pkg/rdp/exec_osx.go b/internal/pkg/rdp/exec_osx.go new file mode 100644 index 0000000..729fc3a --- /dev/null +++ b/internal/pkg/rdp/exec_osx.go @@ -0,0 +1,7 @@ +// +build darwin + +package rdp + +func CommandProvider(filePath string) (cmd string, args []string) { + return "open", []string{filePath} +} diff --git a/internal/pkg/rdp/exec_test.go b/internal/pkg/rdp/exec_test.go new file mode 100644 index 0000000..446c0de --- /dev/null +++ b/internal/pkg/rdp/exec_test.go @@ -0,0 +1,24 @@ +package rdp + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestExecutor(t *testing.T) { + stdOut := &bytes.Buffer{} + const filePath = "file123" + e := &Executor{ + CommandProvider: func(fp string) (cmd string, args []string) { + assert.Equal(t, filePath, fp) + return "echo", []string{"123"} + }, + StdOut: stdOut, + } + + err := e.StartRdp(filePath) + assert.NoError(t, err) + assert.Equal(t, "123\n", stdOut.String()) +} diff --git a/internal/pkg/rdp/exec_win.go b/internal/pkg/rdp/exec_win.go new file mode 100644 index 0000000..ab9f6d8 --- /dev/null +++ b/internal/pkg/rdp/exec_win.go @@ -0,0 +1,7 @@ +// +build windows + +package rdp + +func CommandProvider(filePath string) (cmd string, args []string) { + return "start", []string{filePath} +} diff --git a/internal/pkg/rdp/file.go b/internal/pkg/rdp/file.go new file mode 100644 index 0000000..6bba4ff --- /dev/null +++ b/internal/pkg/rdp/file.go @@ -0,0 +1,107 @@ +package rdp + +import ( + "io" + "strconv" + "strings" + + "github.com/sirupsen/logrus" +) + +const ( + ScreenWidthPlaceholder = "SCREEN_WIDTH" + ScreenHeightPlaceholder = "SCREEN_HEIGHT" + AddressPlaceholder = "ADDRESS" + UserNamePlaceholder = "USER_NAME" + defaultScreenWidth = 1024 + defaultScreenHeight = 768 +) + +const template = `screen mode id:i:1 +use multimon:i:0 +desktopwidth:i:{{SCREEN_WIDTH}} +desktopheight:i:{{SCREEN_HEIGHT}} +client bpp:i:32 +winposstr:s:0,3,0,0,800,600a +compression:i:1 +keyboardhook:i:2 +audiocapturemode:i:0 +videoplaybackmode:i:1 +connection type:i:7 +networkautodetect:i:1 +bandwidthautodetect:i:1 +displayconnectionbar:i:1 +enableworkspacereconnect:i:0 +disable wallpaper:i:0 +allow font smoothing:i:0 +allow desktop composition:i:0 +disable full window drag:i:1 +disable menu anims:i:1 +disable themes:i:0 +disable cursor setting:i:0 +bitmapcachepersistenable:i:1 +full address:s:{{ADDRESS}} +audiomode:i:2 +redirectprinters:i:0 +redirectcomports:i:0 +redirectsmartcards:i:0 +redirectclipboard:i:1 +redirectposdevices:i:0 +drivestoredirect:s: +autoreconnection enabled:i:1 +authentication level:i:2 +prompt for credentials:i:0 +negotiate security layer:i:1 +remoteapplicationmode:i:0 +alternate shell:s: +shell working directory:s: +gatewayhostname:s: +gatewayusagemethod:i:4 +gatewaycredentialssource:i:4 +gatewayprofileusagemethod:i:0 +promptcredentialonce:i:0 +gatewaybrokeringtype:i:0 +use redirection server name:i:0 +rdgiskdcproxy:i:0 +kdcproxyname:s: +username:s:{{USER_NAME}} +` + +type FileInput struct { + Address string + ScreenHeight int + ScreenWidth int + UserName string +} + +func WriteRdpFile(fi FileInput, w io.Writer) error { + if fi.ScreenWidth == 0 { + fi.ScreenWidth = defaultScreenWidth + } + + if fi.ScreenHeight == 0 { + fi.ScreenHeight = defaultScreenHeight + } + + placeholderValues := map[string]string{ + ScreenWidthPlaceholder: strconv.Itoa(fi.ScreenWidth), + ScreenHeightPlaceholder: strconv.Itoa(fi.ScreenHeight), + AddressPlaceholder: fi.Address, + UserNamePlaceholder: fi.UserName, + } + + content := template + for k, v := range placeholderValues { + content = strings.ReplaceAll(content, "{{"+k+"}}", v) + } + + logrus.Debugf("created a rdp file") + logrus.Debug(content) + + _, err := w.Write([]byte(content)) + if err != nil { + return err + } + + return nil +} diff --git a/internal/pkg/rdp/file_test.go b/internal/pkg/rdp/file_test.go new file mode 100644 index 0000000..27af9ef --- /dev/null +++ b/internal/pkg/rdp/file_test.go @@ -0,0 +1,73 @@ +package rdp + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestWriteRdpFile(t *testing.T) { + fileInput := FileInput{ + Address: "node1.rport.io:63231", + ScreenHeight: 600, + ScreenWidth: 800, + UserName: "Monster", + } + + buf := &bytes.Buffer{} + + err := WriteRdpFile(fileInput, buf) + assert.NoError(t, err) + + expectedContent := `screen mode id:i:1 +use multimon:i:0 +desktopwidth:i:800 +desktopheight:i:600 +client bpp:i:32 +winposstr:s:0,3,0,0,800,600a +compression:i:1 +keyboardhook:i:2 +audiocapturemode:i:0 +videoplaybackmode:i:1 +connection type:i:7 +networkautodetect:i:1 +bandwidthautodetect:i:1 +displayconnectionbar:i:1 +enableworkspacereconnect:i:0 +disable wallpaper:i:0 +allow font smoothing:i:0 +allow desktop composition:i:0 +disable full window drag:i:1 +disable menu anims:i:1 +disable themes:i:0 +disable cursor setting:i:0 +bitmapcachepersistenable:i:1 +full address:s:node1.rport.io:63231 +audiomode:i:2 +redirectprinters:i:0 +redirectcomports:i:0 +redirectsmartcards:i:0 +redirectclipboard:i:1 +redirectposdevices:i:0 +drivestoredirect:s: +autoreconnection enabled:i:1 +authentication level:i:2 +prompt for credentials:i:0 +negotiate security layer:i:1 +remoteapplicationmode:i:0 +alternate shell:s: +shell working directory:s: +gatewayhostname:s: +gatewayusagemethod:i:4 +gatewaycredentialssource:i:4 +gatewayprofileusagemethod:i:0 +promptcredentialonce:i:0 +gatewaybrokeringtype:i:0 +use redirection server name:i:0 +rdgiskdcproxy:i:0 +kdcproxyname:s: +username:s:Monster +` + assert.Equal(t, expectedContent, buf.String()) +}