diff --git a/src/jetstream/plugins/cfappssh/app_ssh.go b/src/jetstream/plugins/cfappssh/app_ssh.go index 671aaba1cc..faa1c8d91c 100644 --- a/src/jetstream/plugins/cfappssh/app_ssh.go +++ b/src/jetstream/plugins/cfappssh/app_ssh.go @@ -10,7 +10,7 @@ import ( "net/http" "net/url" "time" - + cfresource "code.cloudfoundry.org/cli/resources" "github.com/cloudfoundry/stratos/src/jetstream/api" "github.com/gorilla/websocket" "github.com/labstack/echo/v4" @@ -45,6 +45,42 @@ type KeyCode struct { Rows int `json:"rows"` } +// When dealing with a v3 enabled cf api, we need to use the processID to build the username to create an SSH +// connection to the app instances web process. +func CheckForV3AvailabilityAndReturnProcessID(appID, baseURL, clientID, token string, apiClient http.Client) (string, error) { + resp, err := apiClient.Head(fmt.Sprintf("%s/%s", baseURL, "v3")) + if resp.StatusCode == http.StatusNotFound { + return appID, nil + } + + if resp.StatusCode != http.StatusOK || err != nil{ + return appID, sendSSHError( "received unexpected error: '%w' or status: '%v' ",err, resp.StatusCode ) + } + + processRequest, err := prepareRequest(baseURL, clientID, token, fmt.Sprintf("/v3/apps/%s/processes/web", appID)) + if err != nil { + return appID, sendSSHError("failed preparing v3 request: %s", err) + } + resp, err = apiClient.Do(processRequest) + if err != nil { + return appID, sendSSHError("failed checking for processes of app_guid %s => '%s': %s", processRequest.URL.Path, appID, err) + } + defer resp.Body.Close() + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + return appID, sendSSHError("failed reading response for '%s': %s", resp.Request.URL.Path, err) + } + appWebProcess := &cfresource.Process{} + err = appWebProcess.UnmarshalJSON(respBytes) + if err != nil { + return appID, sendSSHError("failed unmarshaling response: '%s' for app_guid '%s': %s", string(respBytes), appID, err) + } + if appWebProcess.GUID == "" { + return appID, sendSSHError("the processID returned was empty: %s", string(respBytes)) + } + return appWebProcess.GUID, nil + +} func (cfAppSsh *CFAppSSH) appSSH(c echo.Context) error { // Need to get info for the endpoint // Get the CNSI and app IDs from route parameters @@ -78,8 +114,22 @@ func (cfAppSsh *CFAppSSH) appSSH(c echo.Context) error { return sendSSHError("Can not get Cloud Foundry Endpoint info") } cfInfo := cfInfoEndpoint.V2Info + appOrProcessGUID := c.Param("appGuid") + + // Refresh token first - makes sure it will be valid when we make the request to get the code + refreshedTokenRec, err := p.RefreshOAuthToken(cnsiRecord.SkipSSLValidation, cnsiRecord.GUID, userGUID, cnsiRecord.ClientId, cnsiRecord.ClientSecret, cnsiRecord.TokenEndpoint) + if err != nil { + return sendSSHError("Couldn't get refresh token for CNSI with GUID %s", cnsiRecord.GUID) + } + // use processID instead of appGUID if we detect V3 availability. V3 apps can have multiple containers within one instance and therefore cannot use the appGUID + // because that appGUID could wrap multiple processIDs each with their own option to connect. + // Until full V3 support is added, this will allow targetting the WEB process only. This is not a limitation of the go code. It intentionally left out for now because + // the UI does not provide an option to choose the nested process container. + appOrProcessGUID, err = CheckForV3AvailabilityAndReturnProcessID(appOrProcessGUID, apiEndpoint.String(), cnsiRecord.ClientId, string(refreshedTokenRec.AuthToken), p.GetHttpClient(cnsiRecord.SkipSSLValidation, cnsiRecord.CACert)) + if err != nil { + return sendSSHError("Failed checking for v3 app: %s", err) + } - appGUID := c.Param("appGuid") appInstance := c.Param("appInstance") host, _, err := net.SplitHostPort(cfInfo.AppSSHEndpoint) @@ -89,36 +139,32 @@ func (cfAppSsh *CFAppSSH) appSSH(c echo.Context) error { // Build the Username // cf:APP-GUID/APP-INSTANCE-INDEX@SSH-ENDPOINT - username := fmt.Sprintf("cf:%s/%s@%s", appGUID, appInstance, host) + username := fmt.Sprintf("cf:%s/%s@%s", appOrProcessGUID, appInstance, host) // Need to get SSH Code - // Refresh token first - makes sure it will be valid when we make the request to get the code - refreshedTokenRec, err := p.RefreshOAuthToken(cnsiRecord.SkipSSLValidation, cnsiRecord.GUID, userGUID, cnsiRecord.ClientId, cnsiRecord.ClientSecret, cnsiRecord.TokenEndpoint) - if err != nil { - return sendSSHError("Couldn't get refresh token for CNSI with GUID %s", cnsiRecord.GUID) - } + code, err := getSSHCode(cnsiRecord.TokenEndpoint, cfInfo.AppSSHOauthCLient, refreshedTokenRec.AuthToken, cnsiRecord.SkipSSLValidation) if err != nil { return sendSSHError("Couldn't get SSH Code: %s", err) } - sshConfig := &ssh.ClientConfig{ User: username, Auth: []ssh.AuthMethod{ ssh.Password(code), }, + HostKeyCallback: sshHostKeyChecker(cfInfo.AppSSHHostKeyFingerprint), } connection, err := ssh.Dial("tcp", cfInfo.AppSSHEndpoint, sshConfig) if err != nil { - return fmt.Errorf("Failed to dial: %s", err) + return sendSSHError("Failed to dial '%s': %s", username, err) } session, err := connection.NewSession() if err != nil { - return fmt.Errorf("Failed to create session: %s", err) + return sendSSHError("Failed to create session: %s", err) } defer connection.Close() @@ -139,17 +185,17 @@ func (cfAppSsh *CFAppSSH) appSSH(c echo.Context) error { // NB: rows, cols if err := session.RequestPty("xterm", 84, 80, modes); err != nil { session.Close() - return fmt.Errorf("request for pseudo terminal failed: %s", err) + return sendSSHError("request for pseudo terminal failed: %s", err) } stdin, err := session.StdinPipe() if err != nil { - return fmt.Errorf("Unable to setup stdin for session: %v", err) + return sendSSHError("Unable to setup stdin for session: %v", err) } stdout, err := session.StdoutPipe() if err != nil { - return fmt.Errorf("Unable to setup stdout for session: %v", err) + return sendSSHError("Unable to setup stdout for session: %v", err) } defer session.Close() @@ -187,7 +233,7 @@ func sendSSHError(format string, a ...interface{}) error { } else { log.Errorf("App SSH Error: "+format, a) } - return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf(format, a...)) + return echo.NewHTTPError(http.StatusInternalServerError, fmt.Errorf(format, a...)) } func sshHostKeyChecker(fingerprint string) ssh.HostKeyCallback { @@ -256,10 +302,10 @@ func pumpStdout(ws *websocket.Conn, r io.Reader, done chan struct{}) { // ErrPreventRedirect - Error to indicate a redirect - used to make a redirect that we want to prevent later var ErrPreventRedirect = errors.New("prevent-redirect") -func getSSHCode(authorizeEndpoint, clientID, token string, skipSSLValidation bool) (string, error) { +func prepareRequest(authorizeEndpoint, clientID, token, path string) (*http.Request, error) { authorizeURL, err := url.Parse(authorizeEndpoint) if err != nil { - return "", err + return nil, err } values := url.Values{} @@ -267,17 +313,20 @@ func getSSHCode(authorizeEndpoint, clientID, token string, skipSSLValidation boo values.Set("grant_type", "authorization_code") values.Set("client_id", clientID) - authorizeURL.Path += "/oauth/authorize" + authorizeURL.Path += path authorizeURL.RawQuery = values.Encode() authorizeReq, err := http.NewRequest("GET", authorizeURL.String(), nil) if err != nil { - return "", err + return nil, err } authorizeReq.Header.Add("authorization", "Bearer "+token) - httpClientWithoutRedirects := &http.Client{ + return authorizeReq, nil +} +func getClientWithoutRedirects(skipSSLValidation bool) *http.Client{ + return &http.Client{ CheckRedirect: func(req *http.Request, _ []*http.Request) error { return ErrPreventRedirect }, @@ -291,6 +340,15 @@ func getSSHCode(authorizeEndpoint, clientID, token string, skipSSLValidation boo TLSHandshakeTimeout: 10 * time.Second, }, } +} + +func getSSHCode(authorizeEndpoint, clientID, token string, skipSSLValidation bool) (string, error) { + authorizeReq, err := prepareRequest(authorizeEndpoint, clientID, token, "/oauth/authorize") + if err != nil { + return "", sendSSHError("Failed preparing request %s", err) + } + httpClientWithoutRedirects := getClientWithoutRedirects(skipSSLValidation) + resp, err := httpClientWithoutRedirects.Do(authorizeReq) if resp != nil { diff --git a/src/jetstream/plugins/cfappssh/app_ssh_test.go b/src/jetstream/plugins/cfappssh/app_ssh_test.go new file mode 100644 index 0000000000..af2ed287d3 --- /dev/null +++ b/src/jetstream/plugins/cfappssh/app_ssh_test.go @@ -0,0 +1,77 @@ +package cfappssh_test + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "regexp" + "testing" + + "github.com/cloudfoundry/stratos/src/jetstream/plugins/cfappssh" +) + +func TestCheckForV3Availability(t *testing.T) { + expectedProcessID := "i-am-process-id" + appGUID := "some-guid" + + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + appWebProcess := map[string]string{ + "AppGUID": "one two three", + "guid": "i-am-process-id", + "Type": "web", + } + re := regexp.MustCompile("^/v3") + + if re.Match([]byte(r.URL.Path)) && r.Method == http.MethodHead { + w.WriteHeader(http.StatusOK) + return + } + if r.URL.Path == fmt.Sprintf("/v3/apps/%s/processes/web", appGUID) && r.Method == http.MethodGet { + body, err := json.Marshal(appWebProcess) + if err != nil { + t.Error("failed creating response body") + } + w.WriteHeader(http.StatusOK) + + w.Write(body) + return + } + })) + defer testServer.Close() + + apiClient := http.Client{} + processID, err := cfappssh.CheckForV3AvailabilityAndReturnProcessID(appGUID, testServer.URL, "","", apiClient) + if err != nil { + t.Errorf("I didn't expect that: %s", err) + } + if processID != expectedProcessID { + t.Errorf("the value should have changed to %s but was %s", expectedProcessID, appGUID) + } +} + +func TestV2InstanceWebProcessSSH(t *testing.T) { + expectedProcessID := "some-guid" + appGUID := "some-guid" + t.Parallel() + + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + re := regexp.MustCompile("^/v3") + t.Log("path", r.URL.Path, "method", r.Method) + + if re.Match([]byte(r.URL.Path)) && r.Method == http.MethodHead { + w.WriteHeader(http.StatusNotFound) + return + } + })) + defer testServer.Close() + + apiClient := http.Client{} + processID, err := cfappssh.CheckForV3AvailabilityAndReturnProcessID(appGUID, testServer.URL, "","", apiClient) + if err != nil { + t.Errorf("I didn't expect that: %s", err) + } + if processID != expectedProcessID { + t.Errorf("the value should NOT have changed. expected %s but was %s", expectedProcessID, processID) + } +}