Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 78 additions & 20 deletions src/jetstream/plugins/cfappssh/app_ssh.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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()
Expand All @@ -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()
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -256,28 +302,31 @@ 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{}
values.Set("response_type", "code")
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
},
Expand All @@ -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 {
Expand Down
77 changes: 77 additions & 0 deletions src/jetstream/plugins/cfappssh/app_ssh_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading