mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
chore: support signed token query param for web terminal (#7197)
* chore: add endpoint to get token for web terminal * chore: support signed token query param for web terminal
This commit is contained in:
@@ -81,6 +81,10 @@ func New(ctx context.Context, options *Options) (*API, error) {
|
||||
r.Get("/", api.licenses)
|
||||
r.Delete("/{id}", api.deleteLicense)
|
||||
})
|
||||
r.Route("/applications/reconnecting-pty-signed-token", func(r chi.Router) {
|
||||
r.Use(apiKeyMiddleware)
|
||||
r.Post("/", api.reconnectingPTYSignedToken)
|
||||
})
|
||||
r.Route("/workspaceproxies", func(r chi.Router) {
|
||||
r.Use(
|
||||
api.moonsEnabledMW,
|
||||
|
||||
@@ -10,10 +10,12 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
agpl "github.com/coder/coder/coderd"
|
||||
"github.com/coder/coder/coderd/audit"
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/httpapi"
|
||||
"github.com/coder/coder/coderd/httpmw"
|
||||
"github.com/coder/coder/coderd/rbac"
|
||||
"github.com/coder/coder/coderd/workspaceapps"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/cryptorand"
|
||||
@@ -314,3 +316,99 @@ func (api *API) workspaceProxyRegister(rw http.ResponseWriter, r *http.Request)
|
||||
AppSecurityKey: api.AppSecurityKey.String(),
|
||||
})
|
||||
}
|
||||
|
||||
// reconnectingPTYSignedToken issues a signed app token for use when connecting
|
||||
// to the reconnecting PTY websocket on an external workspace proxy. This is set
|
||||
// by the client as a query parameter when connecting.
|
||||
//
|
||||
// @Summary Issue signed app token for reconnecting PTY
|
||||
// @ID issue-signed-app-token-for-reconnecting-pty
|
||||
// @Security CoderSessionToken
|
||||
// @Tags Applications Enterprise
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body codersdk.IssueReconnectingPTYSignedTokenRequest true "Issue reconnecting PTY signed token request"
|
||||
// @Success 200 {object} codersdk.IssueReconnectingPTYSignedTokenResponse
|
||||
// @Router /applications/reconnecting-pty-signed-token [post]
|
||||
// @x-apidocgen {"skip": true}
|
||||
func (api *API) reconnectingPTYSignedToken(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
apiKey := httpmw.APIKey(r)
|
||||
if !api.Authorize(r, rbac.ActionCreate, apiKey) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
|
||||
var req codersdk.IssueReconnectingPTYSignedTokenRequest
|
||||
if !httpapi.Read(ctx, rw, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
u, err := url.Parse(req.URL)
|
||||
if err == nil && u.Scheme != "ws" && u.Scheme != "wss" {
|
||||
err = xerrors.Errorf("invalid URL scheme %q, expected 'ws' or 'wss'", u.Scheme)
|
||||
}
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid URL.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Assert the URL is a valid reconnecting-pty URL.
|
||||
expectedPath := fmt.Sprintf("/api/v2/workspaceagents/%s/pty", req.AgentID.String())
|
||||
if u.Path != expectedPath {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid URL path.",
|
||||
Detail: "The provided URL is not a valid reconnecting PTY endpoint URL.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
scheme, err := api.AGPL.ValidWorkspaceAppHostname(ctx, u.Host, agpl.ValidWorkspaceAppHostnameOpts{
|
||||
// Only allow the proxy access URL as a hostname since we don't need a
|
||||
// ticket for the primary dashboard URL terminal.
|
||||
AllowPrimaryAccessURL: false,
|
||||
AllowPrimaryWildcard: false,
|
||||
AllowProxyAccessURL: true,
|
||||
AllowProxyWildcard: false,
|
||||
})
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to verify hostname in URL.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if scheme == "" {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid hostname in URL.",
|
||||
Detail: "The hostname must be the primary wildcard app hostname, a workspace proxy access URL or a workspace proxy wildcard app hostname.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
_, tokenStr, ok := api.AGPL.WorkspaceAppsProvider.Issue(ctx, rw, r, workspaceapps.IssueTokenRequest{
|
||||
AppRequest: workspaceapps.Request{
|
||||
AccessMethod: workspaceapps.AccessMethodTerminal,
|
||||
BasePath: u.Path,
|
||||
AgentNameOrID: req.AgentID.String(),
|
||||
},
|
||||
SessionToken: httpmw.APITokenFromRequest(r),
|
||||
// The following fields aren't required as long as the request is authed
|
||||
// with a valid API key.
|
||||
PathAppBaseURL: "",
|
||||
AppHostname: "",
|
||||
// The following fields are empty for terminal apps.
|
||||
AppPath: "",
|
||||
AppQuery: "",
|
||||
})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusOK, codersdk.IssueReconnectingPTYSignedTokenResponse{
|
||||
SignedToken: tokenStr,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package coderd_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -206,3 +209,213 @@ func TestIssueSignedAppToken(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestReconnectingPTYSignedToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dv := coderdtest.DeploymentValues(t)
|
||||
dv.Experiments = []string{
|
||||
string(codersdk.ExperimentMoons),
|
||||
"*",
|
||||
}
|
||||
|
||||
db, pubsub := dbtestutil.NewDB(t)
|
||||
client := coderdenttest.New(t, &coderdenttest.Options{
|
||||
Options: &coderdtest.Options{
|
||||
DeploymentValues: dv,
|
||||
Database: db,
|
||||
Pubsub: pubsub,
|
||||
IncludeProvisionerDaemon: true,
|
||||
},
|
||||
})
|
||||
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
_ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
|
||||
Features: license.Features{
|
||||
codersdk.FeatureWorkspaceProxy: 1,
|
||||
},
|
||||
})
|
||||
|
||||
// Create a workspace + apps
|
||||
authToken := uuid.NewString()
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionApply: echo.ProvisionApplyWithAgent(authToken),
|
||||
})
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
build := coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
workspace.LatestBuild = build
|
||||
|
||||
// Connect an agent to the workspace
|
||||
agentID := build.Resources[0].Agents[0].ID
|
||||
agentClient := agentsdk.New(client.URL)
|
||||
agentClient.SetSessionToken(authToken)
|
||||
agentCloser := agent.New(agent.Options{
|
||||
Client: agentClient,
|
||||
Logger: slogtest.Make(t, nil).Named("agent").Leveled(slog.LevelDebug),
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
_ = agentCloser.Close()
|
||||
})
|
||||
|
||||
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
||||
|
||||
createProxyCtx := testutil.Context(t, testutil.WaitLong)
|
||||
proxyRes, err := client.CreateWorkspaceProxy(createProxyCtx, codersdk.CreateWorkspaceProxyRequest{
|
||||
Name: namesgenerator.GetRandomName(1),
|
||||
Icon: "/emojis/flag.png",
|
||||
URL: "https://" + namesgenerator.GetRandomName(1) + ".com",
|
||||
WildcardHostname: "*.sub.example.com",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
u, err := url.Parse(proxyRes.Proxy.URL)
|
||||
require.NoError(t, err)
|
||||
if u.Scheme == "https" {
|
||||
u.Scheme = "wss"
|
||||
} else {
|
||||
u.Scheme = "ws"
|
||||
}
|
||||
u.Path = fmt.Sprintf("/api/v2/workspaceagents/%s/pty", agentID.String())
|
||||
|
||||
t.Run("Validate", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
res, err := client.IssueReconnectingPTYSignedToken(ctx, codersdk.IssueReconnectingPTYSignedTokenRequest{
|
||||
URL: "",
|
||||
AgentID: uuid.Nil,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Empty(t, res)
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("BadURL", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
res, err := client.IssueReconnectingPTYSignedToken(ctx, codersdk.IssueReconnectingPTYSignedTokenRequest{
|
||||
URL: ":",
|
||||
AgentID: agentID,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Empty(t, res)
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
||||
require.Contains(t, sdkErr.Response.Message, "Invalid URL")
|
||||
})
|
||||
|
||||
t.Run("BadURL", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
u := *u
|
||||
u.Scheme = "ftp"
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
res, err := client.IssueReconnectingPTYSignedToken(ctx, codersdk.IssueReconnectingPTYSignedTokenRequest{
|
||||
URL: u.String(),
|
||||
AgentID: agentID,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Empty(t, res)
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
||||
require.Contains(t, sdkErr.Response.Message, "Invalid URL")
|
||||
require.Contains(t, sdkErr.Response.Detail, "scheme")
|
||||
})
|
||||
|
||||
t.Run("BadURLPath", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
u := *u
|
||||
u.Path = "/hello"
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
res, err := client.IssueReconnectingPTYSignedToken(ctx, codersdk.IssueReconnectingPTYSignedTokenRequest{
|
||||
URL: u.String(),
|
||||
AgentID: agentID,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Empty(t, res)
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
||||
require.Contains(t, sdkErr.Response.Message, "Invalid URL")
|
||||
require.Contains(t, sdkErr.Response.Detail, "The provided URL is not a valid reconnecting PTY endpoint URL")
|
||||
})
|
||||
|
||||
t.Run("BadHostname", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
u := *u
|
||||
u.Host = "badhostname.com"
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
res, err := client.IssueReconnectingPTYSignedToken(ctx, codersdk.IssueReconnectingPTYSignedTokenRequest{
|
||||
URL: u.String(),
|
||||
AgentID: agentID,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Empty(t, res)
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
||||
require.Contains(t, sdkErr.Response.Message, "Invalid hostname in URL")
|
||||
})
|
||||
|
||||
t.Run("NoToken", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
unauthedClient := codersdk.New(client.URL)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
res, err := unauthedClient.IssueReconnectingPTYSignedToken(ctx, codersdk.IssueReconnectingPTYSignedTokenRequest{
|
||||
URL: u.String(),
|
||||
AgentID: agentID,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Empty(t, res)
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusUnauthorized, sdkErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("NoPermissions", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
userClient, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
res, err := userClient.IssueReconnectingPTYSignedToken(ctx, codersdk.IssueReconnectingPTYSignedTokenRequest{
|
||||
URL: u.String(),
|
||||
AgentID: agentID,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Empty(t, res)
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("OK", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
res, err := client.IssueReconnectingPTYSignedToken(ctx, codersdk.IssueReconnectingPTYSignedTokenRequest{
|
||||
URL: u.String(),
|
||||
AgentID: agentID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, res.SignedToken)
|
||||
|
||||
// The token is validated in the apptest suite, so we don't need to
|
||||
// validate it here.
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user