From f50b3ec0c1e226b3268412efa1e1ad04548bb901 Mon Sep 17 00:00:00 2001 From: royroyee Date: Fri, 12 Apr 2024 16:16:55 +0900 Subject: [PATCH] Implement websh session sharing command --- api/websh/types.go | 17 +++++ api/websh/websh.go | 71 +++++++++++++++++++-- cmd/websh/websh.go | 48 ++++++++++++-- cmd/websh/websh_test.go | 136 ++++++++++++++++++++++++++++++++-------- go.mod | 2 + go.sum | 2 + 6 files changed, 238 insertions(+), 38 deletions(-) diff --git a/api/websh/types.go b/api/websh/types.go index 11d4dc6..1505723 100644 --- a/api/websh/types.go +++ b/api/websh/types.go @@ -1,5 +1,7 @@ package websh +import "time" + type SessionRequest struct { Rows int `json:"rows"` Cols int `json:"cols"` @@ -19,3 +21,18 @@ type SessionResponse struct { RemoteIP string `json:"remote_ip"` WebsocketURL string `json:"websocket_url"` } + +type ShareResponse struct { + SharedURL string `json:"shared_url"` + Password string `json:"password"` + ReadOnly bool `json:"read_only"` + Expiration time.Time `json:"expiration"` +} + +type ShareRequest struct { + ReadOnly bool `json:"read_only"` +} + +type JoinRequest struct { + Password string `json:"password"` +} diff --git a/api/websh/websh.go b/api/websh/websh.go index 79d556f..bd4e804 100644 --- a/api/websh/websh.go +++ b/api/websh/websh.go @@ -12,7 +12,9 @@ import ( "golang.org/x/term" "io" "net/http" + "net/url" "os" + "path" "time" ) @@ -20,18 +22,41 @@ const ( createSessionURL = "/api/websh/sessions/" ) -func CreateWebshConnection(ac *client.AlpaconClient, serverName, username, groupname string) (SessionResponse, error) { - var sessionResponse SessionResponse - serverID, err := server.GetServerIDByName(ac, serverName) +func JoinWebshSession(ac *client.AlpaconClient, sharedURL, password string) (SessionResponse, error) { + parsedURL, err := url.Parse(sharedURL) + if err != nil { + return SessionResponse{}, err + } + + sessionID := parsedURL.Query().Get("session") + if sessionID == "" { + return SessionResponse{}, errors.New("Invalid URL format") + } + + joinRequest := &JoinRequest{ + Password: password, + } + + responseBody, err := ac.SendPostRequest(utils.BuildURL(createSessionURL, path.Join("", sessionID, "join"), nil), joinRequest) + if err != nil { + return SessionResponse{}, err + } + var response SessionResponse + err = json.Unmarshal(responseBody, &response) if err != nil { - return sessionResponse, err + return SessionResponse{}, nil } - return createWebshSession(ac, serverID, username, groupname) + return response, nil } // Create new websh session -func createWebshSession(ac *client.AlpaconClient, serverID, username, groupname string) (SessionResponse, error) { +func CreateWebshSession(ac *client.AlpaconClient, serverName, username, groupname string, share, readOnly bool) (SessionResponse, error) { + serverID, err := server.GetServerIDByName(ac, serverName) + if err != nil { + return SessionResponse{}, err + } + width, height, err := term.GetSize(int(os.Stdin.Fd())) if err != nil { return SessionResponse{}, err @@ -56,6 +81,19 @@ func createWebshSession(ac *client.AlpaconClient, serverID, username, groupname return SessionResponse{}, nil } + if share { + shareRequest := &ShareRequest{ + ReadOnly: readOnly, + } + var shareResponse ShareResponse + responseBody, err = ac.SendPostRequest(utils.BuildURL(createSessionURL, path.Join(response.ID, "share"), nil), shareRequest) + err = json.Unmarshal(responseBody, &shareResponse) + if err != nil { + return SessionResponse{}, nil + } + sharingInfo(shareResponse) + } + return response, nil } @@ -151,3 +189,24 @@ func writeToServer(conn *websocket.Conn, inputChan <-chan string, done chan<- er } } } + +func sharingInfo(response ShareResponse) { + header := `Share the following URL to allow access for the current session to someone else. +**Note: The invitee will be required to enter the provided password to access the websh terminal.**` + + instructions := ` +To join the shared session: +1. Execute the following command in a terminal: + $ alpacon websh join --url="%s" --password="%s" + +2. Or, directly access the session via the shared URL in a web browser.` + + fmt.Println(header) + fmt.Printf(instructions, response.SharedURL, response.Password) + fmt.Println() + fmt.Println("Session Details:") + fmt.Println("Share URL: ", response.SharedURL) + fmt.Println("Password: ", response.Password) + fmt.Println("Read Only: ", response.ReadOnly) + fmt.Println("Expiration: ", utils.TimeUtils(response.Expiration)) +} diff --git a/cmd/websh/websh.go b/cmd/websh/websh.go index f080036..81eb5b0 100644 --- a/cmd/websh/websh.go +++ b/cmd/websh/websh.go @@ -33,27 +33,42 @@ var WebshCmd = &cobra.Command{ // Run a command as [USER_NAME]/[GROUP_NAME] alpacon websh -u [USER_NAME] -g [GROUP_NAME] [SERVER_NAME] [COMMAND] + + // Open a websh terminal and share the current terminal to others via a temporary link + alpacon websh [SERVER NAME] --share + alpacon websh [SERVER NAME] --share --read-only true + + // Join an existing shared session + alpacon websh join --url [SHARED_URL] --password [PASSWORD] Flags: -r Run the websh terminal as the root user. -u / --username [USER_NAME] Specify the username under which the command should be executed. -g / --groupname [GROUP_NAME] Specify the group name under which the command should be executed. + + -s, --share Share the current terminal to others via a temporary link. + --url [SHARED_URL] Specify the URL of the shared session to join. + -p, --password [PASSWORD] Specify the password required to access the shared session. + --read-only [true|false] Set the shared session to read-only mode (default is false). Note: - - All flags (-r, -u, -g) must be placed before the [SERVER_NAME]. + - All flags must be placed before the [SERVER_NAME]. - The -u (or --username) and -g (or --groupname) flags require an argument specifying the user or group name, respectively. `, DisableFlagParsing: true, Run: func(cmd *cobra.Command, args []string) { var ( - username, groupname, serverName string - commandArgs []string + username, groupname, serverName, url, password string + commandArgs []string + share, readOnly bool ) for i := 0; i < len(args); i++ { switch { case args[i] == "-r" || args[i] == "--root": username = "root" + case args[i] == "-s" || args[i] == "--share": + share = true case args[i] == "-h" || args[i] == "--help": cmd.Help() return @@ -61,6 +76,20 @@ var WebshCmd = &cobra.Command{ username, i = extractValue(args, i) case strings.HasPrefix(args[i], "-g") || strings.HasPrefix(args[i], "--groupname"): groupname, i = extractValue(args, i) + case strings.HasPrefix(args[i], "--url"): + url, i = extractValue(args, i) + case strings.HasPrefix(args[i], "-p") || strings.HasPrefix(args[i], "--password"): + password, i = extractValue(args, i) + case strings.HasPrefix(args[i], "--read-only"): + var value string + value, i = extractValue(args, i) + if value == "" || strings.TrimSpace(strings.ToLower(value)) == "true" { + readOnly = true + } else if strings.TrimSpace(strings.ToLower(value)) == "false" { + readOnly = false + } else { + utils.CliError("The 'read only' value must be either 'true' or 'false'.") + } default: if serverName == "" { serverName = args[i] @@ -80,7 +109,16 @@ var WebshCmd = &cobra.Command{ utils.CliError("Connection to Alpacon API failed: %s. Consider re-logging.", err) } - if len(commandArgs) > 0 { + if serverName == "join" { + if url == "" || password == "" { + utils.CliError("Both URL and password are required.") + } + session, err := websh.JoinWebshSession(alpaconClient, url, password) + if err != nil { + utils.CliError("Failed to join the session: %s.", err) + } + websh.OpenNewTerminal(alpaconClient, session) + } else if len(commandArgs) > 0 { command := strings.Join(commandArgs, " ") result, err := event.RunCommand(alpaconClient, serverName, command, username, groupname) if err != nil { @@ -88,7 +126,7 @@ var WebshCmd = &cobra.Command{ } fmt.Println(result) } else { - session, err := websh.CreateWebshConnection(alpaconClient, serverName, username, groupname) + session, err := websh.CreateWebshSession(alpaconClient, serverName, username, groupname, share, readOnly) if err != nil { utils.CliError("Failed to create the websh connection: %s.", err) } diff --git a/cmd/websh/websh_test.go b/cmd/websh/websh_test.go index 4c1ae02..94eefa6 100644 --- a/cmd/websh/websh_test.go +++ b/cmd/websh/websh_test.go @@ -1,6 +1,7 @@ package websh import ( + "github.com/alpacanetworks/alpacon-cli/utils" "github.com/stretchr/testify/assert" "strings" "testing" @@ -10,17 +11,20 @@ func TestCommandParsing(t *testing.T) { tests := []struct { testName string args []string - expectRoot bool expectUsername string expectGroupname string expectServerName string expectCommandArgs []string + expectShare bool + expectJoin bool + expectReadOnly bool + expectUrl string + expectPassword string }{ { testName: "RootAccessToServer", args: []string{"-r", "prod-server", "df", "-h"}, - expectRoot: true, - expectUsername: "", + expectUsername: "root", expectGroupname: "", expectServerName: "prod-server", expectCommandArgs: []string{"df", "-h"}, @@ -28,7 +32,6 @@ func TestCommandParsing(t *testing.T) { { testName: "ExecuteUpdateAsAdminSysadmin", args: []string{"-u", "admin", "-g", "sysadmin", "update-server", "sudo", "apt-get", "update"}, - expectRoot: false, expectUsername: "admin", expectGroupname: "sysadmin", expectServerName: "update-server", @@ -37,7 +40,6 @@ func TestCommandParsing(t *testing.T) { { testName: "DockerComposeDeploymentWithFlags", args: []string{"deploy-server", "docker-compose", "-f", "/home/admin/deploy/docker-compose.yml", "up", "-d"}, - expectRoot: false, expectUsername: "", expectGroupname: "", expectServerName: "deploy-server", @@ -46,7 +48,6 @@ func TestCommandParsing(t *testing.T) { { testName: "VerboseListInFileServer", args: []string{"file-server", "ls", "-l", "/var/www"}, - expectRoot: false, expectUsername: "", expectGroupname: "", expectServerName: "file-server", @@ -55,8 +56,7 @@ func TestCommandParsing(t *testing.T) { { testName: "MisplacedFlagOrderWithRoot", args: []string{"-r", "df", "-h"}, - expectRoot: true, - expectUsername: "", + expectUsername: "root", expectGroupname: "", expectServerName: "df", expectCommandArgs: []string(nil), @@ -64,7 +64,6 @@ func TestCommandParsing(t *testing.T) { { testName: "UnrecognizedFlagWithEchoCommand", args: []string{"-x", "unknown-server", "echo", "Hello World"}, - expectRoot: false, expectUsername: "", expectGroupname: "", expectServerName: "-x", @@ -73,7 +72,6 @@ func TestCommandParsing(t *testing.T) { { testName: "AdminSysadminAccessToMultiFlagServer", args: []string{"--username=admin", "--groupname=sysadmin", "multi-flag-server", "uptime"}, - expectRoot: false, expectUsername: "admin", expectGroupname: "sysadmin", expectServerName: "multi-flag-server", @@ -82,7 +80,6 @@ func TestCommandParsing(t *testing.T) { { testName: "CommandLineArgsResembleFlags", args: []string{"--username", "admin", "server-name", "--fake-flag", "value"}, - expectRoot: false, expectUsername: "admin", expectGroupname: "", expectServerName: "server-name", @@ -91,7 +88,6 @@ func TestCommandParsing(t *testing.T) { { testName: "SysadminGroupWithMixedSyntax", args: []string{"-g=sysadmin", "server-name", "echo", "hello world"}, - expectRoot: false, expectUsername: "", expectGroupname: "sysadmin", expectServerName: "server-name", @@ -100,7 +96,6 @@ func TestCommandParsing(t *testing.T) { { testName: "HelpRequestedViaCombinedFlags", args: []string{"-rh"}, - expectRoot: false, expectUsername: "", expectGroupname: "", expectServerName: "-rh", @@ -109,7 +104,6 @@ func TestCommandParsing(t *testing.T) { { testName: "InvalidUsageDetected", args: []string{"-u", "user", "-x", "unknown-flag", "server-name", "cmd"}, - expectRoot: false, expectUsername: "user", expectGroupname: "", expectServerName: "-x", @@ -118,7 +112,6 @@ func TestCommandParsing(t *testing.T) { { testName: "ValidFlagsFollowedByInvalidFlag", args: []string{"-u", "user", "-g", "group", "-x", "server-name", "cmd"}, - expectRoot: false, expectUsername: "user", expectGroupname: "group", expectServerName: "-x", @@ -127,7 +120,6 @@ func TestCommandParsing(t *testing.T) { { testName: "FlagsIntermixedWithCommandArgs", args: []string{"server-name", "-u", "user", "cmd", "-g", "group"}, - expectRoot: false, expectUsername: "user", expectGroupname: "", expectServerName: "server-name", @@ -136,43 +128,129 @@ func TestCommandParsing(t *testing.T) { { testName: "FlagsAndCommandArgsIntertwined", args: []string{"server-name", "-u", "user", "cmd", "-g", "group"}, - expectRoot: false, expectUsername: "user", expectGroupname: "", expectServerName: "server-name", expectCommandArgs: []string{"cmd", "-g", "group"}, }, + { + testName: "ShareSessionWithFlags", + args: []string{"test-server", "--share"}, + expectUsername: "", + expectGroupname: "", + expectServerName: "test-server", + expectCommandArgs: nil, + expectShare: true, + expectJoin: false, + expectReadOnly: false, + expectUrl: "", + expectPassword: "", + }, + { + testName: "JoinSharedSession", + args: []string{"join", "--url", "http://localhost:3000/websh/join?session=abcd", "--password", "1234"}, + expectUsername: "", + expectGroupname: "", + expectServerName: "join", + expectCommandArgs: nil, + expectShare: false, + expectJoin: true, + expectReadOnly: false, + expectUrl: "http://localhost:3000/websh/join?session=abcd", + expectPassword: "1234", + }, + { + testName: "ReadOnlySharedSession", + args: []string{"test-server", "--share", "--read-only"}, + expectUsername: "", + expectGroupname: "", + expectServerName: "test-server", + expectCommandArgs: nil, + expectShare: true, + expectJoin: false, + expectReadOnly: true, + expectUrl: "", + expectPassword: "", + }, + { + testName: "ReadOnlySharedSession2", + args: []string{"test-server", "--share", "--read-only=True"}, + expectUsername: "", + expectGroupname: "", + expectServerName: "test-server", + expectCommandArgs: nil, + expectShare: true, + expectJoin: false, + expectReadOnly: true, + expectUrl: "", + expectPassword: "", + }, + { + testName: "InvalidFlagCombination", + args: []string{"--share", "join", "--url", "http://localhost:3000/websh/join?session=abcd"}, + expectUsername: "", + expectGroupname: "", + expectServerName: "join", + expectCommandArgs: nil, + expectShare: true, + expectJoin: true, + expectReadOnly: false, + expectUrl: "http://localhost:3000/websh/join?session=abcd", + expectPassword: "", + }, } for _, tc := range tests { t.Run(tc.testName, func(t *testing.T) { - root, username, groupname, serverName, commandArgs := executeTestCommand(tc.args) + username, groupname, serverName, commandArgs, share, join, readOnly, url, password := executeTestCommand(tc.args) - assert.Equal(t, tc.expectRoot, root, "Mismatch in root flag") assert.Equal(t, tc.expectUsername, username, "Mismatch in username") assert.Equal(t, tc.expectGroupname, groupname, "Mismatch in groupname") assert.Equal(t, tc.expectServerName, serverName, "Mismatch in server name") assert.Equal(t, tc.expectCommandArgs, commandArgs, "Mismatch in command arguments") - + assert.Equal(t, tc.expectShare, share, "Mismatch in share flag") + assert.Equal(t, tc.expectJoin, join, "Mismatch in join functionality") + assert.Equal(t, tc.expectReadOnly, readOnly, "Mismatch in read-only flag") + assert.Equal(t, tc.expectUrl, url, "Mismatch in URL for joining") + assert.Equal(t, tc.expectPassword, password, "Mismatch in password for joining") }) } } -func executeTestCommand(args []string) (bool, string, string, string, []string) { - var root bool - var username, groupname, serverName string - var commandArgs []string +func executeTestCommand(args []string) (string, string, string, []string, bool, bool, bool, string, string) { + var ( + share, join, readOnly bool + username, groupname, serverName, url, password string + commandArgs []string + ) for i := 0; i < len(args); i++ { switch { case args[i] == "-r" || args[i] == "--root": - root = true + username = "root" + case args[i] == "-s" || args[i] == "--share": + share = true case args[i] == "-h" || args[i] == "--help": - return root, username, groupname, serverName, commandArgs + return username, groupname, serverName, commandArgs, share, join, readOnly, url, password + case strings.HasPrefix(args[i], "-u") || strings.HasPrefix(args[i], "--username"): username, i = extractValue(args, i) case strings.HasPrefix(args[i], "-g") || strings.HasPrefix(args[i], "--groupname"): groupname, i = extractValue(args, i) + case strings.HasPrefix(args[i], "--url"): + url, i = extractValue(args, i) + case strings.HasPrefix(args[i], "-p") || strings.HasPrefix(args[i], "--password"): + password, i = extractValue(args, i) + case strings.HasPrefix(args[i], "--read-only"): + var value string + value, i = extractValue(args, i) + if value == "" || strings.TrimSpace(strings.ToLower(value)) == "true" { + readOnly = true + } else if strings.TrimSpace(strings.ToLower(value)) == "false" { + readOnly = false + } else { + utils.CliError("The 'read only' value must be either 'true' or 'false'.") + } default: if serverName == "" { serverName = args[i] @@ -183,5 +261,9 @@ func executeTestCommand(args []string) (bool, string, string, string, []string) } } - return root, username, groupname, serverName, commandArgs + if serverName == "join" { + join = true + } + + return username, groupname, serverName, commandArgs, share, join, readOnly, url, password } diff --git a/go.mod b/go.mod index 298de13..9cb91d0 100644 --- a/go.mod +++ b/go.mod @@ -14,11 +14,13 @@ require ( ) require ( + github.com/cpuguy83/go-md2man/v2 v2.0.3 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/mattn/go-runewidth v0.0.9 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect golang.org/x/net v0.17.0 // indirect diff --git a/go.sum b/go.sum index 1bed324..0d8ab8c 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,4 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.3 h1:qMCsGGgs+MAzDFyp9LpAe1Lqy/fY/qCovCm0qnXZOBM= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 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= @@ -21,6 +22,7 @@ github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=