From 05447f87ab07bbd820c19438e83a251d711f5faa Mon Sep 17 00:00:00 2001 From: nouseforaname <34882943+nouseforaname@users.noreply.github.com> Date: Thu, 28 Aug 2025 12:23:16 +0200 Subject: [PATCH 1/5] enable v3 app ssh via UI for web: type process currently the UI fails establishing an ssh connection to apps that rely on cc v3 functionality. Some basic apps ( e.g. `cf push test -b staticfile_buildpack` seems to produce an app that the UI can still ssh into ) still work but anything that relies on v3 functionality ( e.g. multi process apps or `cf push --strategy rolling` ) will fail. The default type for a process of a pushed app is `web`. This commit enables using the UI to ssh to the default web process of V3 apps. It left out adding support for all other process types since the UI misses the feature to select nested processes. It only displays the `instances` that an app has running but none of the nested elements of V3 apps. we only want to ignore 404 errors when checking for v3 availability. other errors may still provide relevant information. some auth related code was extracted from getSSHCode so it's reusable in CheckForV3... in the same reasoning requesting the token and refreshing it was moved up. --- src/jetstream/plugins/cfappssh/app_ssh.go | 94 +++++++++++++++---- .../plugins/cfappssh/app_ssh_test.go | 81 ++++++++++++++++ 2 files changed, 156 insertions(+), 19 deletions(-) create mode 100644 src/jetstream/plugins/cfappssh/app_ssh_test.go diff --git a/src/jetstream/plugins/cfappssh/app_ssh.go b/src/jetstream/plugins/cfappssh/app_ssh.go index 671aaba1cc..eeec1d41a8 100644 --- a/src/jetstream/plugins/cfappssh/app_ssh.go +++ b/src/jetstream/plugins/cfappssh/app_ssh.go @@ -11,6 +11,7 @@ import ( "net/url" "time" + cloudFoundryResource "code.cloudfoundry.org/cli/resources" "github.com/cloudfoundry/stratos/src/jetstream/api" "github.com/gorilla/websocket" "github.com/labstack/echo/v4" @@ -45,6 +46,37 @@ type KeyCode struct { Rows int `json:"rows"` } +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 { + 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 := &cloudFoundryResource.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 + } + return appID, err +} func (cfAppSsh *CFAppSSH) appSSH(c echo.Context) error { // Need to get info for the endpoint // Get the CNSI and app IDs from route parameters @@ -64,6 +96,7 @@ func (cfAppSsh *CFAppSSH) appSSH(c echo.Context) error { apiEndpoint := cnsiRecord.APIEndpoint cfPlugin, err := p.GetEndpointTypeSpec("cf") + if err != nil { return sendSSHError("Can not get Cloud Foundry endpoint plugin") } @@ -79,7 +112,22 @@ func (cfAppSsh *CFAppSSH) appSSH(c echo.Context) error { } cfInfo := cfInfoEndpoint.V2Info - appGUID := c.Param("appGuid") + 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) + } + appInstance := c.Param("appInstance") host, _, err := net.SplitHostPort(cfInfo.AppSSHEndpoint) @@ -89,36 +137,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 +183,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 +231,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 +300,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 +311,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 +338,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..d7cdb65755 --- /dev/null +++ b/src/jetstream/plugins/cfappssh/app_ssh_test.go @@ -0,0 +1,81 @@ +package cfappssh_test + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "regexp" + "testing" + + "github.com/cloudfoundry/stratos/src/jetstream/plugins/cfappssh" + "github.com/labstack/echo/v4" +) + +type FakeContext struct { + echo.Context +} + +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") + + 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) + } +} From f20d427be48e9ab20185d732239ef053bd8e4f9a Mon Sep 17 00:00:00 2001 From: nouseforaname <34882943+nouseforaname@users.noreply.github.com> Date: Thu, 28 Aug 2025 13:58:04 +0200 Subject: [PATCH 2/5] review findings - remove unneeded struct - remove newline - go fmt --- src/jetstream/plugins/cfappssh/app_ssh.go | 10 +++++----- src/jetstream/plugins/cfappssh/app_ssh_test.go | 16 ++++++---------- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/src/jetstream/plugins/cfappssh/app_ssh.go b/src/jetstream/plugins/cfappssh/app_ssh.go index eeec1d41a8..2309f23965 100644 --- a/src/jetstream/plugins/cfappssh/app_ssh.go +++ b/src/jetstream/plugins/cfappssh/app_ssh.go @@ -46,6 +46,8 @@ 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 { @@ -63,10 +65,10 @@ func CheckForV3AvailabilityAndReturnProcessID(appID, baseURL, clientID, token st 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) + return appID, sendSSHError("failed reading response for '%s': %s", resp.Request.URL.Path, err) } - appWebProcess := &cloudFoundryResource.Process{} - err = appWebProcess.UnmarshalJSON(respBytes); + appWebProcess := &cloudFoundryResource.Process{} + err = appWebProcess.UnmarshalJSON(respBytes) if err != nil { return appID, sendSSHError("failed unmarshaling response: '%s' for app_guid '%s': %s", string(respBytes), appID, err) } @@ -96,7 +98,6 @@ func (cfAppSsh *CFAppSSH) appSSH(c echo.Context) error { apiEndpoint := cnsiRecord.APIEndpoint cfPlugin, err := p.GetEndpointTypeSpec("cf") - if err != nil { return sendSSHError("Can not get Cloud Foundry endpoint plugin") } @@ -111,7 +112,6 @@ 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 diff --git a/src/jetstream/plugins/cfappssh/app_ssh_test.go b/src/jetstream/plugins/cfappssh/app_ssh_test.go index d7cdb65755..af2ed287d3 100644 --- a/src/jetstream/plugins/cfappssh/app_ssh_test.go +++ b/src/jetstream/plugins/cfappssh/app_ssh_test.go @@ -9,22 +9,17 @@ import ( "testing" "github.com/cloudfoundry/stratos/src/jetstream/plugins/cfappssh" - "github.com/labstack/echo/v4" ) -type FakeContext struct { - echo.Context -} - 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){ + 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", + "guid": "i-am-process-id", + "Type": "web", } re := regexp.MustCompile("^/v3") @@ -48,7 +43,7 @@ func TestCheckForV3Availability(t *testing.T) { apiClient := http.Client{} processID, err := cfappssh.CheckForV3AvailabilityAndReturnProcessID(appGUID, testServer.URL, "","", apiClient) if err != nil { - t.Errorf("I didn't expect that: %s",err) + 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) @@ -62,6 +57,7 @@ func TestV2InstanceWebProcessSSH(t *testing.T) { 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) @@ -73,7 +69,7 @@ func TestV2InstanceWebProcessSSH(t *testing.T) { apiClient := http.Client{} processID, err := cfappssh.CheckForV3AvailabilityAndReturnProcessID(appGUID, testServer.URL, "","", apiClient) if err != nil { - t.Errorf("I didn't expect that: %s",err) + 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) From cff73dc3effb45dee49b1b93bd624b69c40c9dd9 Mon Sep 17 00:00:00 2001 From: nouseforaname <34882943+nouseforaname@users.noreply.github.com> Date: Mon, 8 Sep 2025 15:02:43 +0200 Subject: [PATCH 3/5] rename import camel case is not expected here --- src/jetstream/plugins/cfappssh/app_ssh.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/jetstream/plugins/cfappssh/app_ssh.go b/src/jetstream/plugins/cfappssh/app_ssh.go index 2309f23965..aac3b25f31 100644 --- a/src/jetstream/plugins/cfappssh/app_ssh.go +++ b/src/jetstream/plugins/cfappssh/app_ssh.go @@ -10,8 +10,7 @@ import ( "net/http" "net/url" "time" - - cloudFoundryResource "code.cloudfoundry.org/cli/resources" + cfresource "code.cloudfoundry.org/cli/resources" "github.com/cloudfoundry/stratos/src/jetstream/api" "github.com/gorilla/websocket" "github.com/labstack/echo/v4" @@ -67,7 +66,7 @@ func CheckForV3AvailabilityAndReturnProcessID(appID, baseURL, clientID, token st if err != nil { return appID, sendSSHError("failed reading response for '%s': %s", resp.Request.URL.Path, err) } - appWebProcess := &cloudFoundryResource.Process{} + 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) From b394b1a95bab81b83ccca357852402f366fbaa11 Mon Sep 17 00:00:00 2001 From: nouseforaname <34882943+nouseforaname@users.noreply.github.com> Date: Mon, 8 Sep 2025 15:25:31 +0200 Subject: [PATCH 4/5] move happy case to the end it should improve readability --- src/jetstream/plugins/cfappssh/app_ssh.go | 51 ++++++++++++----------- 1 file changed, 27 insertions(+), 24 deletions(-) diff --git a/src/jetstream/plugins/cfappssh/app_ssh.go b/src/jetstream/plugins/cfappssh/app_ssh.go index aac3b25f31..711773bb26 100644 --- a/src/jetstream/plugins/cfappssh/app_ssh.go +++ b/src/jetstream/plugins/cfappssh/app_ssh.go @@ -52,31 +52,34 @@ func CheckForV3AvailabilityAndReturnProcessID(appID, baseURL, clientID, token st if resp.StatusCode == http.StatusNotFound { return appID, nil } - if resp.StatusCode == http.StatusOK { - 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 + + if resp.StatusCode != http.StatusOK { + return appID, err } - return appID, err + + 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 From 24df204ee2d53c030dc8edce6f7b9e35287a0c3a Mon Sep 17 00:00:00 2001 From: nouseforaname <34882943+nouseforaname@users.noreply.github.com> Date: Mon, 8 Sep 2025 15:29:56 +0200 Subject: [PATCH 5/5] wrap error and status for clarity if something goes wrong, that will at least leave a hint in the logs --- src/jetstream/plugins/cfappssh/app_ssh.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/jetstream/plugins/cfappssh/app_ssh.go b/src/jetstream/plugins/cfappssh/app_ssh.go index 711773bb26..faa1c8d91c 100644 --- a/src/jetstream/plugins/cfappssh/app_ssh.go +++ b/src/jetstream/plugins/cfappssh/app_ssh.go @@ -53,8 +53,8 @@ func CheckForV3AvailabilityAndReturnProcessID(appID, baseURL, clientID, token st return appID, nil } - if resp.StatusCode != http.StatusOK { - return appID, err + 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))