mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
chore: add workspace proxies to the backend (#7032)
Co-authored-by: Dean Sheather <dean@deansheather.com>
This commit is contained in:
Generated
+126
-5
@@ -5000,7 +5000,7 @@ const docTemplate = `{
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Templates"
|
||||
"Enterprise"
|
||||
],
|
||||
"summary": "Create workspace proxy",
|
||||
"operationId": "create-workspace-proxy",
|
||||
@@ -5025,6 +5025,48 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/workspaceproxies/me/issue-signed-app-token": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Enterprise"
|
||||
],
|
||||
"summary": "Issue signed workspace app token",
|
||||
"operationId": "issue-signed-workspace-app-token",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Issue signed app token request",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/workspaceapps.IssueTokenRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Created",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/wsproxysdk.IssueSignedAppTokenResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-apidocgen": {
|
||||
"skip": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"/workspaces": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -6321,6 +6363,10 @@ const docTemplate = `{
|
||||
"codersdk.BuildInfoResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dashboard_url": {
|
||||
"description": "DashboardURL is the URL to hit the deployment's dashboard.\nFor external workspace proxies, this is the coderd they are connected\nto.",
|
||||
"type": "string"
|
||||
},
|
||||
"external_url": {
|
||||
"description": "ExternalURL references the current Coder version.\nFor production builds, this will link directly to a release. For development builds, this will link to a commit.",
|
||||
"type": "string"
|
||||
@@ -6328,6 +6374,9 @@ const docTemplate = `{
|
||||
"version": {
|
||||
"description": "Version returns the semantic version of the build.",
|
||||
"type": "string"
|
||||
},
|
||||
"workspace_proxy": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -9514,10 +9563,6 @@ const docTemplate = `{
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"organization_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
@@ -10054,6 +10099,82 @@ const docTemplate = `{
|
||||
},
|
||||
"url.Userinfo": {
|
||||
"type": "object"
|
||||
},
|
||||
"workspaceapps.AccessMethod": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"path",
|
||||
"subdomain",
|
||||
"terminal"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"AccessMethodPath",
|
||||
"AccessMethodSubdomain",
|
||||
"AccessMethodTerminal"
|
||||
]
|
||||
},
|
||||
"workspaceapps.IssueTokenRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"app_hostname": {
|
||||
"description": "AppHostname is the optional hostname for subdomain apps on the external\nproxy. It must start with an asterisk.",
|
||||
"type": "string"
|
||||
},
|
||||
"app_path": {
|
||||
"description": "AppPath is the path of the user underneath the app base path.",
|
||||
"type": "string"
|
||||
},
|
||||
"app_query": {
|
||||
"description": "AppQuery is the query parameters the user provided in the app request.",
|
||||
"type": "string"
|
||||
},
|
||||
"app_request": {
|
||||
"$ref": "#/definitions/workspaceapps.Request"
|
||||
},
|
||||
"path_app_base_url": {
|
||||
"description": "PathAppBaseURL is required.",
|
||||
"type": "string"
|
||||
},
|
||||
"session_token": {
|
||||
"description": "SessionToken is the session token provided by the user.",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"workspaceapps.Request": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"access_method": {
|
||||
"$ref": "#/definitions/workspaceapps.AccessMethod"
|
||||
},
|
||||
"agent_name_or_id": {
|
||||
"description": "AgentNameOrID is not required if the workspace has only one agent.",
|
||||
"type": "string"
|
||||
},
|
||||
"app_slug_or_port": {
|
||||
"type": "string"
|
||||
},
|
||||
"base_path": {
|
||||
"description": "BasePath of the app. For path apps, this is the path prefix in the router\nfor this particular app. For subdomain apps, this should be \"/\". This is\nused for setting the cookie path.",
|
||||
"type": "string"
|
||||
},
|
||||
"username_or_id": {
|
||||
"description": "For the following fields, if the AccessMethod is AccessMethodTerminal,\nthen only AgentNameOrID may be set and it must be a UUID. The other\nfields must be left blank.",
|
||||
"type": "string"
|
||||
},
|
||||
"workspace_name_or_id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"wsproxysdk.IssueSignedAppTokenResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"signed_token_str": {
|
||||
"description": "SignedTokenStr should be set as a cookie on the response.",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"securityDefinitions": {
|
||||
|
||||
Generated
+116
-5
@@ -4399,7 +4399,7 @@
|
||||
],
|
||||
"consumes": ["application/json"],
|
||||
"produces": ["application/json"],
|
||||
"tags": ["Templates"],
|
||||
"tags": ["Enterprise"],
|
||||
"summary": "Create workspace proxy",
|
||||
"operationId": "create-workspace-proxy",
|
||||
"parameters": [
|
||||
@@ -4423,6 +4423,42 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/workspaceproxies/me/issue-signed-app-token": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"consumes": ["application/json"],
|
||||
"produces": ["application/json"],
|
||||
"tags": ["Enterprise"],
|
||||
"summary": "Issue signed workspace app token",
|
||||
"operationId": "issue-signed-workspace-app-token",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Issue signed app token request",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/workspaceapps.IssueTokenRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Created",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/wsproxysdk.IssueSignedAppTokenResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-apidocgen": {
|
||||
"skip": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"/workspaces": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -5639,6 +5675,10 @@
|
||||
"codersdk.BuildInfoResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dashboard_url": {
|
||||
"description": "DashboardURL is the URL to hit the deployment's dashboard.\nFor external workspace proxies, this is the coderd they are connected\nto.",
|
||||
"type": "string"
|
||||
},
|
||||
"external_url": {
|
||||
"description": "ExternalURL references the current Coder version.\nFor production builds, this will link directly to a release. For development builds, this will link to a commit.",
|
||||
"type": "string"
|
||||
@@ -5646,6 +5686,9 @@
|
||||
"version": {
|
||||
"description": "Version returns the semantic version of the build.",
|
||||
"type": "string"
|
||||
},
|
||||
"workspace_proxy": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -8602,10 +8645,6 @@
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"organization_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
@@ -9123,6 +9162,78 @@
|
||||
},
|
||||
"url.Userinfo": {
|
||||
"type": "object"
|
||||
},
|
||||
"workspaceapps.AccessMethod": {
|
||||
"type": "string",
|
||||
"enum": ["path", "subdomain", "terminal"],
|
||||
"x-enum-varnames": [
|
||||
"AccessMethodPath",
|
||||
"AccessMethodSubdomain",
|
||||
"AccessMethodTerminal"
|
||||
]
|
||||
},
|
||||
"workspaceapps.IssueTokenRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"app_hostname": {
|
||||
"description": "AppHostname is the optional hostname for subdomain apps on the external\nproxy. It must start with an asterisk.",
|
||||
"type": "string"
|
||||
},
|
||||
"app_path": {
|
||||
"description": "AppPath is the path of the user underneath the app base path.",
|
||||
"type": "string"
|
||||
},
|
||||
"app_query": {
|
||||
"description": "AppQuery is the query parameters the user provided in the app request.",
|
||||
"type": "string"
|
||||
},
|
||||
"app_request": {
|
||||
"$ref": "#/definitions/workspaceapps.Request"
|
||||
},
|
||||
"path_app_base_url": {
|
||||
"description": "PathAppBaseURL is required.",
|
||||
"type": "string"
|
||||
},
|
||||
"session_token": {
|
||||
"description": "SessionToken is the session token provided by the user.",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"workspaceapps.Request": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"access_method": {
|
||||
"$ref": "#/definitions/workspaceapps.AccessMethod"
|
||||
},
|
||||
"agent_name_or_id": {
|
||||
"description": "AgentNameOrID is not required if the workspace has only one agent.",
|
||||
"type": "string"
|
||||
},
|
||||
"app_slug_or_port": {
|
||||
"type": "string"
|
||||
},
|
||||
"base_path": {
|
||||
"description": "BasePath of the app. For path apps, this is the path prefix in the router\nfor this particular app. For subdomain apps, this should be \"/\". This is\nused for setting the cookie path.",
|
||||
"type": "string"
|
||||
},
|
||||
"username_or_id": {
|
||||
"description": "For the following fields, if the AccessMethod is AccessMethodTerminal,\nthen only AgentNameOrID may be set and it must be a UUID. The other\nfields must be left blank.",
|
||||
"type": "string"
|
||||
},
|
||||
"workspace_name_or_id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"wsproxysdk.IssueSignedAppTokenResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"signed_token_str": {
|
||||
"description": "SignedTokenStr should be set as a cookie on the response.",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"securityDefinitions": {
|
||||
|
||||
+4
-4
@@ -25,7 +25,7 @@ func AuthorizeFilter[O rbac.Objecter](h *HTTPAuthorizer, r *http.Request, action
|
||||
h.Logger.Error(r.Context(), "filter failed",
|
||||
slog.Error(err),
|
||||
slog.F("user_id", roles.Actor.ID),
|
||||
slog.F("username", roles.Username),
|
||||
slog.F("username", roles.ActorName),
|
||||
slog.F("roles", roles.Actor.SafeRoleNames()),
|
||||
slog.F("scope", roles.Actor.SafeScopeName()),
|
||||
slog.F("route", r.URL.Path),
|
||||
@@ -77,8 +77,8 @@ func (h *HTTPAuthorizer) Authorize(r *http.Request, action rbac.Action, object r
|
||||
// in the early days
|
||||
logger.Warn(r.Context(), "unauthorized",
|
||||
slog.F("roles", roles.Actor.SafeRoleNames()),
|
||||
slog.F("user_id", roles.Actor.ID),
|
||||
slog.F("username", roles.Username),
|
||||
slog.F("actor_id", roles.Actor.ID),
|
||||
slog.F("actor_name", roles.ActorName),
|
||||
slog.F("scope", roles.Actor.SafeScopeName()),
|
||||
slog.F("route", r.URL.Path),
|
||||
slog.F("action", action),
|
||||
@@ -129,7 +129,7 @@ func (api *API) checkAuthorization(rw http.ResponseWriter, r *http.Request) {
|
||||
api.Logger.Debug(ctx, "check-auth",
|
||||
slog.F("my_id", httpmw.APIKey(r).UserID),
|
||||
slog.F("got_id", auth.Actor.ID),
|
||||
slog.F("name", auth.Username),
|
||||
slog.F("name", auth.ActorName),
|
||||
slog.F("roles", auth.Actor.SafeRoleNames()),
|
||||
slog.F("scope", auth.Actor.SafeScopeName()),
|
||||
)
|
||||
|
||||
+29
-12
@@ -36,9 +36,9 @@ import (
|
||||
"tailscale.com/util/singleflight"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/buildinfo"
|
||||
|
||||
// Used to serve the Swagger endpoint
|
||||
"github.com/coder/coder/buildinfo"
|
||||
// Used for swagger docs.
|
||||
_ "github.com/coder/coder/coderd/apidoc"
|
||||
"github.com/coder/coder/coderd/audit"
|
||||
"github.com/coder/coder/coderd/awsidentity"
|
||||
@@ -290,8 +290,8 @@ func New(options *Options) *API {
|
||||
OIDC: options.OIDCConfig,
|
||||
}
|
||||
|
||||
r := chi.NewRouter()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
r := chi.NewRouter()
|
||||
api := &API{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
@@ -340,16 +340,18 @@ func New(options *Options) *API {
|
||||
api.workspaceAppServer = &workspaceapps.Server{
|
||||
Logger: options.Logger.Named("workspaceapps"),
|
||||
|
||||
DashboardURL: api.AccessURL,
|
||||
AccessURL: api.AccessURL,
|
||||
Hostname: api.AppHostname,
|
||||
HostnameRegex: api.AppHostnameRegex,
|
||||
DeploymentValues: options.DeploymentValues,
|
||||
RealIPConfig: options.RealIPConfig,
|
||||
DashboardURL: api.AccessURL,
|
||||
AccessURL: api.AccessURL,
|
||||
Hostname: api.AppHostname,
|
||||
HostnameRegex: api.AppHostnameRegex,
|
||||
RealIPConfig: options.RealIPConfig,
|
||||
|
||||
SignedTokenProvider: api.WorkspaceAppsProvider,
|
||||
WorkspaceConnCache: api.workspaceAgentCache,
|
||||
AppSecurityKey: options.AppSecurityKey,
|
||||
|
||||
DisablePathApps: options.DeploymentValues.DisablePathApps.Value(),
|
||||
SecureAuthCookie: options.DeploymentValues.SecureAuthCookie.Value(),
|
||||
}
|
||||
|
||||
apiKeyMiddleware := httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
|
||||
@@ -367,6 +369,14 @@ func New(options *Options) *API {
|
||||
DisableSessionExpiryRefresh: options.DeploymentValues.DisableSessionExpiryRefresh.Value(),
|
||||
Optional: false,
|
||||
})
|
||||
// Same as the first but it's optional.
|
||||
apiKeyMiddlewareOptional := httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
|
||||
DB: options.Database,
|
||||
OAuth2Configs: oauthConfigs,
|
||||
RedirectToLogin: false,
|
||||
DisableSessionExpiryRefresh: options.DeploymentValues.DisableSessionExpiryRefresh.Value(),
|
||||
Optional: true,
|
||||
})
|
||||
|
||||
// API rate limit middleware. The counter is local and not shared between
|
||||
// replicas or instances of this middleware.
|
||||
@@ -389,7 +399,7 @@ func New(options *Options) *API {
|
||||
//
|
||||
// Workspace apps do their own auth and must be BEFORE the auth
|
||||
// middleware.
|
||||
api.workspaceAppServer.SubdomainAppMW(apiRateLimiter),
|
||||
api.workspaceAppServer.HandleSubdomain(apiRateLimiter),
|
||||
// Build-Version is helpful for debugging.
|
||||
func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -450,7 +460,7 @@ func New(options *Options) *API {
|
||||
// All CSP errors will be logged
|
||||
r.Post("/csp/reports", api.logReportCSPViolations)
|
||||
|
||||
r.Get("/buildinfo", buildInfo)
|
||||
r.Get("/buildinfo", buildInfo(api.AccessURL))
|
||||
r.Route("/deployment", func(r chi.Router) {
|
||||
r.Use(apiKeyMiddleware)
|
||||
r.Get("/config", api.deploymentValues)
|
||||
@@ -661,7 +671,14 @@ func New(options *Options) *API {
|
||||
})
|
||||
r.Route("/{workspaceagent}", func(r chi.Router) {
|
||||
r.Use(
|
||||
apiKeyMiddleware,
|
||||
// Allow either API key or external workspace proxy auth and require it.
|
||||
apiKeyMiddlewareOptional,
|
||||
httpmw.ExtractWorkspaceProxy(httpmw.ExtractWorkspaceProxyConfig{
|
||||
DB: options.Database,
|
||||
Optional: true,
|
||||
}),
|
||||
httpmw.RequireAPIKeyOrWorkspaceProxyAuth(),
|
||||
|
||||
httpmw.ExtractWorkspaceAgentParam(options.Database),
|
||||
httpmw.ExtractWorkspaceParam(options.Database),
|
||||
)
|
||||
|
||||
@@ -180,6 +180,7 @@ var (
|
||||
rbac.ResourceUser.Type: {rbac.ActionCreate, rbac.ActionUpdate, rbac.ActionDelete},
|
||||
rbac.ResourceUserData.Type: {rbac.ActionCreate, rbac.ActionUpdate},
|
||||
rbac.ResourceWorkspace.Type: {rbac.ActionUpdate},
|
||||
rbac.ResourceWorkspaceExecution.Type: {rbac.ActionCreate},
|
||||
}),
|
||||
Org: map[string][]rbac.Permission{},
|
||||
User: []rbac.Permission{},
|
||||
|
||||
@@ -1697,6 +1697,10 @@ func (q *querier) GetWorkspaceProxyByID(ctx context.Context, id uuid.UUID) (data
|
||||
return fetch(q.log, q.auth, q.db.GetWorkspaceProxyByID)(ctx, id)
|
||||
}
|
||||
|
||||
func (q *querier) GetWorkspaceProxyByHostname(ctx context.Context, hostname string) (database.WorkspaceProxy, error) {
|
||||
return fetch(q.log, q.auth, q.db.GetWorkspaceProxyByHostname)(ctx, hostname)
|
||||
}
|
||||
|
||||
func (q *querier) InsertWorkspaceProxy(ctx context.Context, arg database.InsertWorkspaceProxyParams) (database.WorkspaceProxy, error) {
|
||||
return insert(q.log, q.auth, rbac.ResourceWorkspaceProxy, q.db.InsertWorkspaceProxy)(ctx, arg)
|
||||
}
|
||||
|
||||
@@ -445,25 +445,25 @@ func (s *MethodTestSuite) TestWorkspaceProxy() {
|
||||
}).Asserts(rbac.ResourceWorkspaceProxy, rbac.ActionCreate)
|
||||
}))
|
||||
s.Run("UpdateWorkspaceProxy", s.Subtest(func(db database.Store, check *expects) {
|
||||
p := dbgen.WorkspaceProxy(s.T(), db, database.WorkspaceProxy{})
|
||||
p, _ := dbgen.WorkspaceProxy(s.T(), db, database.WorkspaceProxy{})
|
||||
check.Args(database.UpdateWorkspaceProxyParams{
|
||||
ID: p.ID,
|
||||
}).Asserts(p, rbac.ActionUpdate)
|
||||
}))
|
||||
s.Run("GetWorkspaceProxyByID", s.Subtest(func(db database.Store, check *expects) {
|
||||
p := dbgen.WorkspaceProxy(s.T(), db, database.WorkspaceProxy{})
|
||||
p, _ := dbgen.WorkspaceProxy(s.T(), db, database.WorkspaceProxy{})
|
||||
check.Args(p.ID).Asserts(p, rbac.ActionRead).Returns(p)
|
||||
}))
|
||||
s.Run("UpdateWorkspaceProxyDeleted", s.Subtest(func(db database.Store, check *expects) {
|
||||
p := dbgen.WorkspaceProxy(s.T(), db, database.WorkspaceProxy{})
|
||||
p, _ := dbgen.WorkspaceProxy(s.T(), db, database.WorkspaceProxy{})
|
||||
check.Args(database.UpdateWorkspaceProxyDeletedParams{
|
||||
ID: p.ID,
|
||||
Deleted: true,
|
||||
}).Asserts(p, rbac.ActionDelete)
|
||||
}))
|
||||
s.Run("GetWorkspaceProxies", s.Subtest(func(db database.Store, check *expects) {
|
||||
p1 := dbgen.WorkspaceProxy(s.T(), db, database.WorkspaceProxy{})
|
||||
p2 := dbgen.WorkspaceProxy(s.T(), db, database.WorkspaceProxy{})
|
||||
p1, _ := dbgen.WorkspaceProxy(s.T(), db, database.WorkspaceProxy{})
|
||||
p2, _ := dbgen.WorkspaceProxy(s.T(), db, database.WorkspaceProxy{})
|
||||
check.Args().Asserts(p1, rbac.ActionRead, p2, rbac.ActionRead).Returns(slice.New(p1, p2))
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -18,10 +19,13 @@ import (
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/httpapi"
|
||||
"github.com/coder/coder/coderd/rbac"
|
||||
"github.com/coder/coder/coderd/util/slice"
|
||||
)
|
||||
|
||||
var validProxyByHostnameRegex = regexp.MustCompile(`^[a-zA-Z0-9.-]+$`)
|
||||
|
||||
// FakeDatabase is helpful for knowing if the underlying db is an in memory fake
|
||||
// database. This is only in the databasefake package, so will only be used
|
||||
// by unit tests.
|
||||
@@ -5093,6 +5097,40 @@ func (q *fakeQuerier) GetWorkspaceProxyByID(_ context.Context, id uuid.UUID) (da
|
||||
return database.WorkspaceProxy{}, sql.ErrNoRows
|
||||
}
|
||||
|
||||
func (q *fakeQuerier) GetWorkspaceProxyByHostname(_ context.Context, hostname string) (database.WorkspaceProxy, error) {
|
||||
q.mutex.Lock()
|
||||
defer q.mutex.Unlock()
|
||||
|
||||
// Return zero rows if this is called with a non-sanitized hostname. The SQL
|
||||
// version of this query does the same thing.
|
||||
if !validProxyByHostnameRegex.MatchString(hostname) {
|
||||
return database.WorkspaceProxy{}, sql.ErrNoRows
|
||||
}
|
||||
|
||||
// This regex matches the SQL version.
|
||||
accessURLRegex := regexp.MustCompile(`[^:]*://` + regexp.QuoteMeta(hostname) + `([:/]?.)*`)
|
||||
|
||||
for _, proxy := range q.workspaceProxies {
|
||||
if proxy.Deleted {
|
||||
continue
|
||||
}
|
||||
if accessURLRegex.MatchString(proxy.Url) {
|
||||
return proxy, nil
|
||||
}
|
||||
|
||||
// Compile the app hostname regex. This is slow sadly.
|
||||
wildcardRegexp, err := httpapi.CompileHostnamePattern(proxy.WildcardHostname)
|
||||
if err != nil {
|
||||
return database.WorkspaceProxy{}, xerrors.Errorf("compile hostname pattern %q for proxy %q (%s): %w", proxy.WildcardHostname, proxy.Name, proxy.ID.String(), err)
|
||||
}
|
||||
if _, ok := httpapi.ExecuteHostnamePattern(wildcardRegexp, hostname); ok {
|
||||
return proxy, nil
|
||||
}
|
||||
}
|
||||
|
||||
return database.WorkspaceProxy{}, sql.ErrNoRows
|
||||
}
|
||||
|
||||
func (q *fakeQuerier) InsertWorkspaceProxy(_ context.Context, arg database.InsertWorkspaceProxyParams) (database.WorkspaceProxy, error) {
|
||||
q.mutex.Lock()
|
||||
defer q.mutex.Unlock()
|
||||
@@ -5104,14 +5142,16 @@ func (q *fakeQuerier) InsertWorkspaceProxy(_ context.Context, arg database.Inser
|
||||
}
|
||||
|
||||
p := database.WorkspaceProxy{
|
||||
ID: arg.ID,
|
||||
Name: arg.Name,
|
||||
Icon: arg.Icon,
|
||||
Url: arg.Url,
|
||||
WildcardHostname: arg.WildcardHostname,
|
||||
CreatedAt: arg.CreatedAt,
|
||||
UpdatedAt: arg.UpdatedAt,
|
||||
Deleted: false,
|
||||
ID: arg.ID,
|
||||
Name: arg.Name,
|
||||
DisplayName: arg.DisplayName,
|
||||
Icon: arg.Icon,
|
||||
Url: arg.Url,
|
||||
WildcardHostname: arg.WildcardHostname,
|
||||
TokenHashedSecret: arg.TokenHashedSecret,
|
||||
CreatedAt: arg.CreatedAt,
|
||||
UpdatedAt: arg.UpdatedAt,
|
||||
Deleted: false,
|
||||
}
|
||||
q.workspaceProxies = append(q.workspaceProxies, p)
|
||||
return p, nil
|
||||
|
||||
@@ -129,6 +129,96 @@ func TestUserOrder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyByHostname(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := dbfake.New()
|
||||
|
||||
// Insert a bunch of different proxies.
|
||||
proxies := []struct {
|
||||
name string
|
||||
accessURL string
|
||||
wildcardHostname string
|
||||
}{
|
||||
{
|
||||
name: "one",
|
||||
accessURL: "https://one.coder.com",
|
||||
wildcardHostname: "*.wildcard.one.coder.com",
|
||||
},
|
||||
{
|
||||
name: "two",
|
||||
accessURL: "https://two.coder.com",
|
||||
wildcardHostname: "*--suffix.two.coder.com",
|
||||
},
|
||||
}
|
||||
for _, p := range proxies {
|
||||
dbgen.WorkspaceProxy(t, db, database.WorkspaceProxy{
|
||||
Name: p.name,
|
||||
Url: p.accessURL,
|
||||
WildcardHostname: p.wildcardHostname,
|
||||
})
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
testHostname string
|
||||
matchProxyName string
|
||||
}{
|
||||
{
|
||||
name: "NoMatch",
|
||||
testHostname: "test.com",
|
||||
matchProxyName: "",
|
||||
},
|
||||
{
|
||||
name: "MatchAccessURL",
|
||||
testHostname: "one.coder.com",
|
||||
matchProxyName: "one",
|
||||
},
|
||||
{
|
||||
name: "MatchWildcard",
|
||||
testHostname: "something.wildcard.one.coder.com",
|
||||
matchProxyName: "one",
|
||||
},
|
||||
{
|
||||
name: "MatchSuffix",
|
||||
testHostname: "something--suffix.two.coder.com",
|
||||
matchProxyName: "two",
|
||||
},
|
||||
{
|
||||
name: "ValidateHostname/1",
|
||||
testHostname: ".*ne.coder.com",
|
||||
matchProxyName: "",
|
||||
},
|
||||
{
|
||||
name: "ValidateHostname/2",
|
||||
testHostname: "https://one.coder.com",
|
||||
matchProxyName: "",
|
||||
},
|
||||
{
|
||||
name: "ValidateHostname/3",
|
||||
testHostname: "one.coder.com:8080/hello",
|
||||
matchProxyName: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
c := c
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
proxy, err := db.GetWorkspaceProxyByHostname(context.Background(), c.testHostname)
|
||||
if c.matchProxyName == "" {
|
||||
require.ErrorIs(t, err, sql.ErrNoRows)
|
||||
require.Empty(t, proxy)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, proxy)
|
||||
require.Equal(t, c.matchProxyName, proxy.Name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func methods(rt reflect.Type) map[string]bool {
|
||||
methods := make(map[string]bool)
|
||||
for i := 0; i < rt.NumMethod(); i++ {
|
||||
|
||||
@@ -338,19 +338,24 @@ func WorkspaceResourceMetadatums(t testing.TB, db database.Store, seed database.
|
||||
return meta
|
||||
}
|
||||
|
||||
func WorkspaceProxy(t testing.TB, db database.Store, orig database.WorkspaceProxy) database.WorkspaceProxy {
|
||||
func WorkspaceProxy(t testing.TB, db database.Store, orig database.WorkspaceProxy) (database.WorkspaceProxy, string) {
|
||||
secret, err := cryptorand.HexString(64)
|
||||
require.NoError(t, err, "generate secret")
|
||||
hashedSecret := sha256.Sum256([]byte(secret))
|
||||
|
||||
resource, err := db.InsertWorkspaceProxy(context.Background(), database.InsertWorkspaceProxyParams{
|
||||
ID: takeFirst(orig.ID, uuid.New()),
|
||||
Name: takeFirst(orig.Name, namesgenerator.GetRandomName(1)),
|
||||
DisplayName: takeFirst(orig.DisplayName, namesgenerator.GetRandomName(1)),
|
||||
Icon: takeFirst(orig.Icon, namesgenerator.GetRandomName(1)),
|
||||
Url: takeFirst(orig.Url, fmt.Sprintf("https://%s.com", namesgenerator.GetRandomName(1))),
|
||||
WildcardHostname: takeFirst(orig.WildcardHostname, fmt.Sprintf(".%s.com", namesgenerator.GetRandomName(1))),
|
||||
CreatedAt: takeFirst(orig.CreatedAt, database.Now()),
|
||||
UpdatedAt: takeFirst(orig.UpdatedAt, database.Now()),
|
||||
ID: takeFirst(orig.ID, uuid.New()),
|
||||
Name: takeFirst(orig.Name, namesgenerator.GetRandomName(1)),
|
||||
DisplayName: takeFirst(orig.DisplayName, namesgenerator.GetRandomName(1)),
|
||||
Icon: takeFirst(orig.Icon, namesgenerator.GetRandomName(1)),
|
||||
Url: takeFirst(orig.Url, fmt.Sprintf("https://%s.com", namesgenerator.GetRandomName(1))),
|
||||
WildcardHostname: takeFirst(orig.WildcardHostname, fmt.Sprintf("*.%s.com", namesgenerator.GetRandomName(1))),
|
||||
TokenHashedSecret: hashedSecret[:],
|
||||
CreatedAt: takeFirst(orig.CreatedAt, database.Now()),
|
||||
UpdatedAt: takeFirst(orig.UpdatedAt, database.Now()),
|
||||
})
|
||||
require.NoError(t, err, "insert app")
|
||||
return resource
|
||||
require.NoError(t, err, "insert proxy")
|
||||
return resource, secret
|
||||
}
|
||||
|
||||
func File(t testing.TB, db database.Store, orig database.File) database.File {
|
||||
|
||||
@@ -78,7 +78,8 @@ func TestGenerator(t *testing.T) {
|
||||
t.Run("WorkspaceProxy", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
db := dbfake.New()
|
||||
exp := dbgen.WorkspaceProxy(t, db, database.WorkspaceProxy{})
|
||||
exp, secret := dbgen.WorkspaceProxy(t, db, database.WorkspaceProxy{})
|
||||
require.Len(t, secret, 64)
|
||||
require.Equal(t, exp, must(db.GetWorkspaceProxyByID(context.Background(), exp.ID)))
|
||||
})
|
||||
|
||||
|
||||
Generated
+8
-1
@@ -647,13 +647,20 @@ CREATE TABLE workspace_proxies (
|
||||
wildcard_hostname text NOT NULL,
|
||||
created_at timestamp with time zone NOT NULL,
|
||||
updated_at timestamp with time zone NOT NULL,
|
||||
deleted boolean NOT NULL
|
||||
deleted boolean NOT NULL,
|
||||
token_hashed_secret bytea NOT NULL
|
||||
);
|
||||
|
||||
COMMENT ON COLUMN workspace_proxies.icon IS 'Expects an emoji character. (/emojis/1f1fa-1f1f8.png)';
|
||||
|
||||
COMMENT ON COLUMN workspace_proxies.url IS 'Full url including scheme of the proxy api url: https://us.example.com';
|
||||
|
||||
COMMENT ON COLUMN workspace_proxies.wildcard_hostname IS 'Hostname with the wildcard for subdomain based app hosting: *.us.example.com';
|
||||
|
||||
COMMENT ON COLUMN workspace_proxies.deleted IS 'Boolean indicator of a deleted workspace proxy. Proxies are soft-deleted.';
|
||||
|
||||
COMMENT ON COLUMN workspace_proxies.token_hashed_secret IS 'Hashed secret is used to authenticate the workspace proxy using a session token.';
|
||||
|
||||
CREATE TABLE workspace_resource_metadata (
|
||||
workspace_resource_id uuid NOT NULL,
|
||||
key character varying(1024) NOT NULL,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE workspace_proxies
|
||||
DROP COLUMN token_hashed_secret;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,22 @@
|
||||
BEGIN;
|
||||
|
||||
-- It's difficult to generate tokens for existing proxies, so we'll just delete
|
||||
-- them if they exist.
|
||||
--
|
||||
-- No one is using this feature yet as of writing this migration, so this is
|
||||
-- fine.
|
||||
DELETE FROM workspace_proxies;
|
||||
|
||||
ALTER TABLE workspace_proxies
|
||||
ADD COLUMN token_hashed_secret bytea NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN workspace_proxies.token_hashed_secret
|
||||
IS 'Hashed secret is used to authenticate the workspace proxy using a session token.';
|
||||
|
||||
COMMENT ON COLUMN workspace_proxies.deleted
|
||||
IS 'Boolean indicator of a deleted workspace proxy. Proxies are soft-deleted.';
|
||||
|
||||
COMMENT ON COLUMN workspace_proxies.icon
|
||||
IS 'Expects an emoji character. (/emojis/1f1fa-1f1f8.png)';
|
||||
|
||||
COMMIT;
|
||||
@@ -1,14 +0,0 @@
|
||||
INSERT INTO workspace_proxies
|
||||
(id, name, display_name, icon, url, wildcard_hostname, created_at, updated_at, deleted)
|
||||
VALUES
|
||||
(
|
||||
'cf8ede8c-ff47-441f-a738-d92e4e34a657',
|
||||
'us',
|
||||
'United States',
|
||||
'/emojis/us.png',
|
||||
'https://us.coder.com',
|
||||
'*.us.coder.com',
|
||||
'2023-03-30 12:00:00.000+02',
|
||||
'2023-03-30 12:00:00.000+02',
|
||||
false
|
||||
);
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
INSERT INTO workspace_proxies
|
||||
(id, name, display_name, icon, url, wildcard_hostname, created_at, updated_at, deleted, token_hashed_secret)
|
||||
VALUES
|
||||
(
|
||||
'cf8ede8c-ff47-441f-a738-d92e4e34a657',
|
||||
'us',
|
||||
'United States',
|
||||
'/emojis/us.png',
|
||||
'https://us.coder.com',
|
||||
'*.us.coder.com',
|
||||
'2023-03-30 12:00:00.000+02',
|
||||
'2023-03-30 12:00:00.000+02',
|
||||
false,
|
||||
'abc123'::bytea
|
||||
);
|
||||
@@ -1674,14 +1674,18 @@ type WorkspaceProxy struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
DisplayName string `db:"display_name" json:"display_name"`
|
||||
Icon string `db:"icon" json:"icon"`
|
||||
// Expects an emoji character. (/emojis/1f1fa-1f1f8.png)
|
||||
Icon string `db:"icon" json:"icon"`
|
||||
// Full url including scheme of the proxy api url: https://us.example.com
|
||||
Url string `db:"url" json:"url"`
|
||||
// Hostname with the wildcard for subdomain based app hosting: *.us.example.com
|
||||
WildcardHostname string `db:"wildcard_hostname" json:"wildcard_hostname"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
Deleted bool `db:"deleted" json:"deleted"`
|
||||
// Boolean indicator of a deleted workspace proxy. Proxies are soft-deleted.
|
||||
Deleted bool `db:"deleted" json:"deleted"`
|
||||
// Hashed secret is used to authenticate the workspace proxy using a session token.
|
||||
TokenHashedSecret []byte `db:"token_hashed_secret" json:"token_hashed_secret"`
|
||||
}
|
||||
|
||||
type WorkspaceResource struct {
|
||||
|
||||
@@ -149,6 +149,14 @@ type sqlcQuerier interface {
|
||||
GetWorkspaceByOwnerIDAndName(ctx context.Context, arg GetWorkspaceByOwnerIDAndNameParams) (Workspace, error)
|
||||
GetWorkspaceByWorkspaceAppID(ctx context.Context, workspaceAppID uuid.UUID) (Workspace, error)
|
||||
GetWorkspaceProxies(ctx context.Context) ([]WorkspaceProxy, error)
|
||||
// Finds a workspace proxy that has an access URL or app hostname that matches
|
||||
// the provided hostname. This is to check if a hostname matches any workspace
|
||||
// proxy.
|
||||
//
|
||||
// The hostname must be sanitized to only contain [a-zA-Z0-9.-] before calling
|
||||
// this query. The scheme, port and path should be stripped.
|
||||
//
|
||||
GetWorkspaceProxyByHostname(ctx context.Context, hostname string) (WorkspaceProxy, error)
|
||||
GetWorkspaceProxyByID(ctx context.Context, id uuid.UUID) (WorkspaceProxy, error)
|
||||
GetWorkspaceResourceByID(ctx context.Context, id uuid.UUID) (WorkspaceResource, error)
|
||||
GetWorkspaceResourceMetadataByResourceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceResourceMetadatum, error)
|
||||
|
||||
@@ -4,6 +4,7 @@ package database_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -127,3 +128,98 @@ func TestInsertWorkspaceAgentStartupLogs(t *testing.T) {
|
||||
})
|
||||
require.True(t, database.IsStartupLogsLimitError(err))
|
||||
}
|
||||
|
||||
func TestProxyByHostname(t *testing.T) {
|
||||
t.Parallel()
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
}
|
||||
sqlDB := testSQLDB(t)
|
||||
err := migrations.Up(sqlDB)
|
||||
require.NoError(t, err)
|
||||
db := database.New(sqlDB)
|
||||
|
||||
// Insert a bunch of different proxies.
|
||||
proxies := []struct {
|
||||
name string
|
||||
accessURL string
|
||||
wildcardHostname string
|
||||
}{
|
||||
{
|
||||
name: "one",
|
||||
accessURL: "https://one.coder.com",
|
||||
wildcardHostname: "*.wildcard.one.coder.com",
|
||||
},
|
||||
{
|
||||
name: "two",
|
||||
accessURL: "https://two.coder.com",
|
||||
wildcardHostname: "*--suffix.two.coder.com",
|
||||
},
|
||||
}
|
||||
for _, p := range proxies {
|
||||
dbgen.WorkspaceProxy(t, db, database.WorkspaceProxy{
|
||||
Name: p.name,
|
||||
Url: p.accessURL,
|
||||
WildcardHostname: p.wildcardHostname,
|
||||
})
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
testHostname string
|
||||
matchProxyName string
|
||||
}{
|
||||
{
|
||||
name: "NoMatch",
|
||||
testHostname: "test.com",
|
||||
matchProxyName: "",
|
||||
},
|
||||
{
|
||||
name: "MatchAccessURL",
|
||||
testHostname: "one.coder.com",
|
||||
matchProxyName: "one",
|
||||
},
|
||||
{
|
||||
name: "MatchWildcard",
|
||||
testHostname: "something.wildcard.one.coder.com",
|
||||
matchProxyName: "one",
|
||||
},
|
||||
{
|
||||
name: "MatchSuffix",
|
||||
testHostname: "something--suffix.two.coder.com",
|
||||
matchProxyName: "two",
|
||||
},
|
||||
{
|
||||
name: "ValidateHostname/1",
|
||||
testHostname: ".*ne.coder.com",
|
||||
matchProxyName: "",
|
||||
},
|
||||
{
|
||||
name: "ValidateHostname/2",
|
||||
testHostname: "https://one.coder.com",
|
||||
matchProxyName: "",
|
||||
},
|
||||
{
|
||||
name: "ValidateHostname/3",
|
||||
testHostname: "one.coder.com:8080/hello",
|
||||
matchProxyName: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
c := c
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
proxy, err := db.GetWorkspaceProxyByHostname(context.Background(), c.testHostname)
|
||||
if c.matchProxyName == "" {
|
||||
require.ErrorIs(t, err, sql.ErrNoRows)
|
||||
require.Empty(t, proxy)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, proxy)
|
||||
require.Equal(t, c.matchProxyName, proxy.Name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2817,7 +2817,7 @@ func (q *sqlQuerier) UpdateProvisionerJobWithCompleteByID(ctx context.Context, a
|
||||
|
||||
const getWorkspaceProxies = `-- name: GetWorkspaceProxies :many
|
||||
SELECT
|
||||
id, name, display_name, icon, url, wildcard_hostname, created_at, updated_at, deleted
|
||||
id, name, display_name, icon, url, wildcard_hostname, created_at, updated_at, deleted, token_hashed_secret
|
||||
FROM
|
||||
workspace_proxies
|
||||
WHERE
|
||||
@@ -2843,6 +2843,7 @@ func (q *sqlQuerier) GetWorkspaceProxies(ctx context.Context) ([]WorkspaceProxy,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Deleted,
|
||||
&i.TokenHashedSecret,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -2857,9 +2858,59 @@ func (q *sqlQuerier) GetWorkspaceProxies(ctx context.Context) ([]WorkspaceProxy,
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getWorkspaceProxyByHostname = `-- name: GetWorkspaceProxyByHostname :one
|
||||
SELECT
|
||||
id, name, display_name, icon, url, wildcard_hostname, created_at, updated_at, deleted, token_hashed_secret
|
||||
FROM
|
||||
workspace_proxies
|
||||
WHERE
|
||||
-- Validate that the @hostname has been sanitized and is not empty. This
|
||||
-- doesn't prevent SQL injection (already prevented by using prepared
|
||||
-- queries), but it does prevent carefully crafted hostnames from matching
|
||||
-- when they shouldn't.
|
||||
--
|
||||
-- Periods don't need to be escaped because they're not special characters
|
||||
-- in SQL matches unlike regular expressions.
|
||||
$1 :: text SIMILAR TO '[a-zA-Z0-9.-]+' AND
|
||||
deleted = false AND
|
||||
|
||||
-- Validate that the hostname matches either the wildcard hostname or the
|
||||
-- access URL (ignoring scheme, port and path).
|
||||
(
|
||||
url SIMILAR TO '[^:]*://' || $1 :: text || '([:/]?%)*' OR
|
||||
$1 :: text LIKE replace(wildcard_hostname, '*', '%')
|
||||
)
|
||||
LIMIT
|
||||
1
|
||||
`
|
||||
|
||||
// Finds a workspace proxy that has an access URL or app hostname that matches
|
||||
// the provided hostname. This is to check if a hostname matches any workspace
|
||||
// proxy.
|
||||
//
|
||||
// The hostname must be sanitized to only contain [a-zA-Z0-9.-] before calling
|
||||
// this query. The scheme, port and path should be stripped.
|
||||
func (q *sqlQuerier) GetWorkspaceProxyByHostname(ctx context.Context, hostname string) (WorkspaceProxy, error) {
|
||||
row := q.db.QueryRowContext(ctx, getWorkspaceProxyByHostname, hostname)
|
||||
var i WorkspaceProxy
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.DisplayName,
|
||||
&i.Icon,
|
||||
&i.Url,
|
||||
&i.WildcardHostname,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Deleted,
|
||||
&i.TokenHashedSecret,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getWorkspaceProxyByID = `-- name: GetWorkspaceProxyByID :one
|
||||
SELECT
|
||||
id, name, display_name, icon, url, wildcard_hostname, created_at, updated_at, deleted
|
||||
id, name, display_name, icon, url, wildcard_hostname, created_at, updated_at, deleted, token_hashed_secret
|
||||
FROM
|
||||
workspace_proxies
|
||||
WHERE
|
||||
@@ -2881,6 +2932,7 @@ func (q *sqlQuerier) GetWorkspaceProxyByID(ctx context.Context, id uuid.UUID) (W
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Deleted,
|
||||
&i.TokenHashedSecret,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -2894,23 +2946,25 @@ INSERT INTO
|
||||
icon,
|
||||
url,
|
||||
wildcard_hostname,
|
||||
token_hashed_secret,
|
||||
created_at,
|
||||
updated_at,
|
||||
deleted
|
||||
)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, false) RETURNING id, name, display_name, icon, url, wildcard_hostname, created_at, updated_at, deleted
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9, false) RETURNING id, name, display_name, icon, url, wildcard_hostname, created_at, updated_at, deleted, token_hashed_secret
|
||||
`
|
||||
|
||||
type InsertWorkspaceProxyParams struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
DisplayName string `db:"display_name" json:"display_name"`
|
||||
Icon string `db:"icon" json:"icon"`
|
||||
Url string `db:"url" json:"url"`
|
||||
WildcardHostname string `db:"wildcard_hostname" json:"wildcard_hostname"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
DisplayName string `db:"display_name" json:"display_name"`
|
||||
Icon string `db:"icon" json:"icon"`
|
||||
Url string `db:"url" json:"url"`
|
||||
WildcardHostname string `db:"wildcard_hostname" json:"wildcard_hostname"`
|
||||
TokenHashedSecret []byte `db:"token_hashed_secret" json:"token_hashed_secret"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) InsertWorkspaceProxy(ctx context.Context, arg InsertWorkspaceProxyParams) (WorkspaceProxy, error) {
|
||||
@@ -2921,6 +2975,7 @@ func (q *sqlQuerier) InsertWorkspaceProxy(ctx context.Context, arg InsertWorkspa
|
||||
arg.Icon,
|
||||
arg.Url,
|
||||
arg.WildcardHostname,
|
||||
arg.TokenHashedSecret,
|
||||
arg.CreatedAt,
|
||||
arg.UpdatedAt,
|
||||
)
|
||||
@@ -2935,6 +2990,7 @@ func (q *sqlQuerier) InsertWorkspaceProxy(ctx context.Context, arg InsertWorkspa
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Deleted,
|
||||
&i.TokenHashedSecret,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -2951,7 +3007,7 @@ SET
|
||||
updated_at = Now()
|
||||
WHERE
|
||||
id = $6
|
||||
RETURNING id, name, display_name, icon, url, wildcard_hostname, created_at, updated_at, deleted
|
||||
RETURNING id, name, display_name, icon, url, wildcard_hostname, created_at, updated_at, deleted, token_hashed_secret
|
||||
`
|
||||
|
||||
type UpdateWorkspaceProxyParams struct {
|
||||
@@ -2983,6 +3039,7 @@ func (q *sqlQuerier) UpdateWorkspaceProxy(ctx context.Context, arg UpdateWorkspa
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Deleted,
|
||||
&i.TokenHashedSecret,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
@@ -7,12 +7,13 @@ INSERT INTO
|
||||
icon,
|
||||
url,
|
||||
wildcard_hostname,
|
||||
token_hashed_secret,
|
||||
created_at,
|
||||
updated_at,
|
||||
deleted
|
||||
)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, false) RETURNING *;
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9, false) RETURNING *;
|
||||
|
||||
-- name: UpdateWorkspaceProxy :one
|
||||
UPDATE
|
||||
@@ -48,6 +49,38 @@ WHERE
|
||||
LIMIT
|
||||
1;
|
||||
|
||||
-- Finds a workspace proxy that has an access URL or app hostname that matches
|
||||
-- the provided hostname. This is to check if a hostname matches any workspace
|
||||
-- proxy.
|
||||
--
|
||||
-- The hostname must be sanitized to only contain [a-zA-Z0-9.-] before calling
|
||||
-- this query. The scheme, port and path should be stripped.
|
||||
--
|
||||
-- name: GetWorkspaceProxyByHostname :one
|
||||
SELECT
|
||||
*
|
||||
FROM
|
||||
workspace_proxies
|
||||
WHERE
|
||||
-- Validate that the @hostname has been sanitized and is not empty. This
|
||||
-- doesn't prevent SQL injection (already prevented by using prepared
|
||||
-- queries), but it does prevent carefully crafted hostnames from matching
|
||||
-- when they shouldn't.
|
||||
--
|
||||
-- Periods don't need to be escaped because they're not special characters
|
||||
-- in SQL matches unlike regular expressions.
|
||||
@hostname :: text SIMILAR TO '[a-zA-Z0-9.-]+' AND
|
||||
deleted = false AND
|
||||
|
||||
-- Validate that the hostname matches either the wildcard hostname or the
|
||||
-- access URL (ignoring scheme, port and path).
|
||||
(
|
||||
url SIMILAR TO '[^:]*://' || @hostname :: text || '([:/]?%)*' OR
|
||||
@hostname :: text LIKE replace(wildcard_hostname, '*', '%')
|
||||
)
|
||||
LIMIT
|
||||
1;
|
||||
|
||||
-- name: GetWorkspaceProxies :many
|
||||
SELECT
|
||||
*
|
||||
|
||||
+10
-5
@@ -2,6 +2,7 @@ package coderd
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/coder/coder/buildinfo"
|
||||
"github.com/coder/coder/coderd/httpapi"
|
||||
@@ -67,11 +68,15 @@ func (api *API) deploymentStats(rw http.ResponseWriter, r *http.Request) {
|
||||
// @Tags General
|
||||
// @Success 200 {object} codersdk.BuildInfoResponse
|
||||
// @Router /buildinfo [get]
|
||||
func buildInfo(rw http.ResponseWriter, r *http.Request) {
|
||||
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.BuildInfoResponse{
|
||||
ExternalURL: buildinfo.ExternalURL(),
|
||||
Version: buildinfo.Version(),
|
||||
})
|
||||
func buildInfo(accessURL *url.URL) http.HandlerFunc {
|
||||
return func(rw http.ResponseWriter, r *http.Request) {
|
||||
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.BuildInfoResponse{
|
||||
ExternalURL: buildinfo.ExternalURL(),
|
||||
Version: buildinfo.Version(),
|
||||
DashboardURL: accessURL.String(),
|
||||
WorkspaceProxy: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// @Summary SSH Config
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package httpmw
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/coder/coder/coderd/httpapi"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
// RequireAPIKeyOrWorkspaceProxyAuth is middleware that should be inserted after
|
||||
// optional ExtractAPIKey and ExtractWorkspaceProxy middlewares to ensure one of
|
||||
// the two authentication methods is provided.
|
||||
//
|
||||
// If both are provided, an error is returned to avoid misuse.
|
||||
func RequireAPIKeyOrWorkspaceProxyAuth() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, hasAPIKey := APIKeyOptional(r)
|
||||
_, hasWorkspaceProxy := WorkspaceProxyOptional(r)
|
||||
|
||||
if hasAPIKey && hasWorkspaceProxy {
|
||||
httpapi.Write(r.Context(), w, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "API key and external proxy authentication provided, but only one is allowed",
|
||||
})
|
||||
return
|
||||
}
|
||||
if !hasAPIKey && !hasWorkspaceProxy {
|
||||
httpapi.Write(r.Context(), w, http.StatusUnauthorized, codersdk.Response{
|
||||
Message: "API key or external proxy authentication required, but none provided",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package httpmw_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/http/httputil"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/database/dbfake"
|
||||
"github.com/coder/coder/coderd/database/dbgen"
|
||||
"github.com/coder/coder/coderd/httpmw"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
func TestRequireAPIKeyOrWorkspaceProxyAuth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("None", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
httpmw.RequireAPIKeyOrWorkspaceProxyAuth()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("should not have been called")
|
||||
})).ServeHTTP(rw, r)
|
||||
|
||||
require.Equal(t, http.StatusUnauthorized, rw.Code)
|
||||
})
|
||||
|
||||
t.Run("APIKey", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
db = dbfake.New()
|
||||
user = dbgen.User(t, db, database.User{})
|
||||
_, token = dbgen.APIKey(t, db, database.APIKey{
|
||||
UserID: user.ID,
|
||||
ExpiresAt: database.Now().AddDate(0, 0, 1),
|
||||
})
|
||||
|
||||
r = httptest.NewRequest("GET", "/", nil)
|
||||
rw = httptest.NewRecorder()
|
||||
)
|
||||
r.Header.Set(codersdk.SessionTokenHeader, token)
|
||||
|
||||
var called int64
|
||||
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
|
||||
DB: db,
|
||||
RedirectToLogin: false,
|
||||
})(
|
||||
httpmw.RequireAPIKeyOrWorkspaceProxyAuth()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt64(&called, 1)
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}))).
|
||||
ServeHTTP(rw, r)
|
||||
|
||||
res := rw.Result()
|
||||
defer res.Body.Close()
|
||||
dump, err := httputil.DumpResponse(res, true)
|
||||
require.NoError(t, err)
|
||||
t.Log(string(dump))
|
||||
|
||||
require.Equal(t, http.StatusOK, rw.Code)
|
||||
require.Equal(t, int64(1), atomic.LoadInt64(&called))
|
||||
})
|
||||
|
||||
t.Run("WorkspaceProxy", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
db = dbfake.New()
|
||||
user = dbgen.User(t, db, database.User{})
|
||||
_, userToken = dbgen.APIKey(t, db, database.APIKey{
|
||||
UserID: user.ID,
|
||||
ExpiresAt: database.Now().AddDate(0, 0, 1),
|
||||
})
|
||||
proxy, proxyToken = dbgen.WorkspaceProxy(t, db, database.WorkspaceProxy{})
|
||||
|
||||
r = httptest.NewRequest("GET", "/", nil)
|
||||
rw = httptest.NewRecorder()
|
||||
)
|
||||
r.Header.Set(codersdk.SessionTokenHeader, userToken)
|
||||
r.Header.Set(httpmw.WorkspaceProxyAuthTokenHeader, fmt.Sprintf("%s:%s", proxy.ID, proxyToken))
|
||||
|
||||
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
|
||||
DB: db,
|
||||
RedirectToLogin: false,
|
||||
})(
|
||||
httpmw.ExtractWorkspaceProxy(httpmw.ExtractWorkspaceProxyConfig{
|
||||
DB: db,
|
||||
})(
|
||||
httpmw.RequireAPIKeyOrWorkspaceProxyAuth()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
})))).
|
||||
ServeHTTP(rw, r)
|
||||
|
||||
res := rw.Result()
|
||||
defer res.Body.Close()
|
||||
dump, err := httputil.DumpResponse(res, true)
|
||||
require.NoError(t, err)
|
||||
t.Log(string(dump))
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, rw.Code)
|
||||
})
|
||||
|
||||
t.Run("Both", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
db = dbfake.New()
|
||||
proxy, token = dbgen.WorkspaceProxy(t, db, database.WorkspaceProxy{})
|
||||
|
||||
r = httptest.NewRequest("GET", "/", nil)
|
||||
rw = httptest.NewRecorder()
|
||||
)
|
||||
r.Header.Set(httpmw.WorkspaceProxyAuthTokenHeader, fmt.Sprintf("%s:%s", proxy.ID, token))
|
||||
|
||||
var called int64
|
||||
httpmw.ExtractWorkspaceProxy(httpmw.ExtractWorkspaceProxyConfig{
|
||||
DB: db,
|
||||
})(
|
||||
httpmw.RequireAPIKeyOrWorkspaceProxyAuth()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt64(&called, 1)
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}))).
|
||||
ServeHTTP(rw, r)
|
||||
|
||||
res := rw.Result()
|
||||
defer res.Body.Close()
|
||||
dump, err := httputil.DumpResponse(res, true)
|
||||
require.NoError(t, err)
|
||||
t.Log(string(dump))
|
||||
|
||||
require.Equal(t, http.StatusOK, rw.Code)
|
||||
require.Equal(t, int64(1), atomic.LoadInt64(&called))
|
||||
})
|
||||
}
|
||||
+33
-10
@@ -47,9 +47,10 @@ type userAuthKey struct{}
|
||||
|
||||
type Authorization struct {
|
||||
Actor rbac.Subject
|
||||
// Username is required for logging and human friendly related
|
||||
// identification.
|
||||
Username string
|
||||
// ActorName is required for logging and human friendly related identification.
|
||||
// It is usually the "username" of the user, but it can be the name of the
|
||||
// external workspace proxy or other service type actor.
|
||||
ActorName string
|
||||
}
|
||||
|
||||
// UserAuthorizationOptional may return the roles and scope used for
|
||||
@@ -99,6 +100,10 @@ type ExtractAPIKeyConfig struct {
|
||||
// will be deleted and the request will continue. If the request is not a
|
||||
// cookie-based request, the request will be rejected with a 401.
|
||||
Optional bool
|
||||
|
||||
// SessionTokenFunc is a custom function that can be used to extract the API
|
||||
// key. If nil, the default behavior is used.
|
||||
SessionTokenFunc func(r *http.Request) string
|
||||
}
|
||||
|
||||
// ExtractAPIKeyMW calls ExtractAPIKey with the given config on each request,
|
||||
@@ -145,7 +150,7 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon
|
||||
// like workspace applications.
|
||||
write := func(code int, response codersdk.Response) (*database.APIKey, *Authorization, bool) {
|
||||
if cfg.RedirectToLogin {
|
||||
RedirectToLogin(rw, r, response.Message)
|
||||
RedirectToLogin(rw, r, nil, response.Message)
|
||||
return nil, nil, false
|
||||
}
|
||||
|
||||
@@ -167,7 +172,11 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon
|
||||
return nil, nil, false
|
||||
}
|
||||
|
||||
token := apiTokenFromRequest(r)
|
||||
tokenFunc := APITokenFromRequest
|
||||
if cfg.SessionTokenFunc != nil {
|
||||
tokenFunc = cfg.SessionTokenFunc
|
||||
}
|
||||
token := tokenFunc(r)
|
||||
if token == "" {
|
||||
return optionalWrite(http.StatusUnauthorized, codersdk.Response{
|
||||
Message: SignedOutErrorMessage,
|
||||
@@ -364,7 +373,7 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon
|
||||
|
||||
// Actor is the user's authorization context.
|
||||
authz := Authorization{
|
||||
Username: roles.Username,
|
||||
ActorName: roles.Username,
|
||||
Actor: rbac.Subject{
|
||||
ID: key.UserID.String(),
|
||||
Roles: rbac.RoleNames(roles.Roles),
|
||||
@@ -376,14 +385,14 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon
|
||||
return &key, &authz, true
|
||||
}
|
||||
|
||||
// apiTokenFromRequest returns the api token from the request.
|
||||
// APITokenFromRequest returns the api token from the request.
|
||||
// Find the session token from:
|
||||
// 1: The cookie
|
||||
// 1: The devurl cookie
|
||||
// 3: The old cookie
|
||||
// 4. The coder_session_token query parameter
|
||||
// 5. The custom auth header
|
||||
func apiTokenFromRequest(r *http.Request) string {
|
||||
func APITokenFromRequest(r *http.Request) string {
|
||||
cookie, err := r.Cookie(codersdk.SessionTokenCookie)
|
||||
if err == nil && cookie.Value != "" {
|
||||
return cookie.Value
|
||||
@@ -432,7 +441,11 @@ func SplitAPIToken(token string) (id string, secret string, err error) {
|
||||
|
||||
// RedirectToLogin redirects the user to the login page with the `message` and
|
||||
// `redirect` query parameters set.
|
||||
func RedirectToLogin(rw http.ResponseWriter, r *http.Request, message string) {
|
||||
//
|
||||
// If dashboardURL is nil, the redirect will be relative to the current
|
||||
// request's host. If it is not nil, the redirect will be absolute with dashboard
|
||||
// url as the host.
|
||||
func RedirectToLogin(rw http.ResponseWriter, r *http.Request, dashboardURL *url.URL, message string) {
|
||||
path := r.URL.Path
|
||||
if r.URL.RawQuery != "" {
|
||||
path += "?" + r.URL.RawQuery
|
||||
@@ -446,6 +459,16 @@ func RedirectToLogin(rw http.ResponseWriter, r *http.Request, message string) {
|
||||
Path: "/login",
|
||||
RawQuery: q.Encode(),
|
||||
}
|
||||
// If dashboardURL is provided, we want to redirect to the dashboard
|
||||
// login page.
|
||||
if dashboardURL != nil {
|
||||
cpy := *dashboardURL
|
||||
cpy.Path = u.Path
|
||||
cpy.RawQuery = u.RawQuery
|
||||
u = &cpy
|
||||
}
|
||||
|
||||
http.Redirect(rw, r, u.String(), http.StatusTemporaryRedirect)
|
||||
// See other forces a GET request rather than keeping the current method
|
||||
// (like temporary redirect does).
|
||||
http.Redirect(rw, r, u.String(), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ func TestAPIKey(t *testing.T) {
|
||||
location, err := res.Location()
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, location.Query().Get("message"))
|
||||
require.Equal(t, http.StatusTemporaryRedirect, res.StatusCode)
|
||||
require.Equal(t, http.StatusSeeOther, res.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("InvalidFormat", func(t *testing.T) {
|
||||
@@ -526,7 +526,7 @@ func TestAPIKey(t *testing.T) {
|
||||
|
||||
res := rw.Result()
|
||||
defer res.Body.Close()
|
||||
require.Equal(t, http.StatusTemporaryRedirect, res.StatusCode)
|
||||
require.Equal(t, http.StatusSeeOther, res.StatusCode)
|
||||
u, err := res.Location()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "/login", u.Path)
|
||||
|
||||
@@ -57,7 +57,7 @@ func ExtractUserParam(db database.Store, redirectToLoginOnMe bool) func(http.Han
|
||||
apiKey, ok := APIKeyOptional(r)
|
||||
if !ok {
|
||||
if redirectToLoginOnMe {
|
||||
RedirectToLogin(rw, r, SignedOutErrorMessage)
|
||||
RedirectToLogin(rw, r, nil, SignedOutErrorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ func ExtractWorkspaceAgent(db database.Store) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
tokenValue := apiTokenFromRequest(r)
|
||||
tokenValue := APITokenFromRequest(r)
|
||||
if tokenValue == "" {
|
||||
httpapi.Write(ctx, rw, http.StatusUnauthorized, codersdk.Response{
|
||||
Message: fmt.Sprintf("Cookie %q must be provided.", codersdk.SessionTokenCookie),
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package httpmw
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/coderd/httpapi"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
const (
|
||||
// WorkspaceProxyAuthTokenHeader is the auth header used for requests from
|
||||
// external workspace proxies.
|
||||
//
|
||||
// The format of an external proxy token is:
|
||||
// <proxy id>:<proxy secret>
|
||||
//
|
||||
//nolint:gosec
|
||||
WorkspaceProxyAuthTokenHeader = "Coder-External-Proxy-Token"
|
||||
)
|
||||
|
||||
type workspaceProxyContextKey struct{}
|
||||
|
||||
// WorkspaceProxyOptional may return the workspace proxy from the ExtractWorkspaceProxy
|
||||
// middleware.
|
||||
func WorkspaceProxyOptional(r *http.Request) (database.WorkspaceProxy, bool) {
|
||||
proxy, ok := r.Context().Value(workspaceProxyContextKey{}).(database.WorkspaceProxy)
|
||||
return proxy, ok
|
||||
}
|
||||
|
||||
// WorkspaceProxy returns the workspace proxy from the ExtractWorkspaceProxy
|
||||
// middleware.
|
||||
func WorkspaceProxy(r *http.Request) database.WorkspaceProxy {
|
||||
proxy, ok := WorkspaceProxyOptional(r)
|
||||
if !ok {
|
||||
panic("developer error: ExtractWorkspaceProxy middleware not provided")
|
||||
}
|
||||
return proxy
|
||||
}
|
||||
|
||||
type ExtractWorkspaceProxyConfig struct {
|
||||
DB database.Store
|
||||
// Optional indicates whether the middleware should be optional. If true,
|
||||
// any requests without the external proxy auth token header will be
|
||||
// allowed to continue and no workspace proxy will be set on the request
|
||||
// context.
|
||||
Optional bool
|
||||
}
|
||||
|
||||
// ExtractWorkspaceProxy extracts the external workspace proxy from the request
|
||||
// using the external proxy auth token header.
|
||||
func ExtractWorkspaceProxy(opts ExtractWorkspaceProxyConfig) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
token := r.Header.Get(WorkspaceProxyAuthTokenHeader)
|
||||
if token == "" {
|
||||
if opts.Optional {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, w, http.StatusUnauthorized, codersdk.Response{
|
||||
Message: "Missing required external proxy token",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Split the token and lookup the corresponding workspace proxy.
|
||||
parts := strings.Split(token, ":")
|
||||
if len(parts) != 2 {
|
||||
httpapi.Write(ctx, w, http.StatusUnauthorized, codersdk.Response{
|
||||
Message: "Invalid external proxy token",
|
||||
})
|
||||
return
|
||||
}
|
||||
proxyID, err := uuid.Parse(parts[0])
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, w, http.StatusUnauthorized, codersdk.Response{
|
||||
Message: "Invalid external proxy token",
|
||||
})
|
||||
return
|
||||
}
|
||||
secret := parts[1]
|
||||
if len(secret) != 64 {
|
||||
httpapi.Write(ctx, w, http.StatusUnauthorized, codersdk.Response{
|
||||
Message: "Invalid external proxy token",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get the proxy.
|
||||
// nolint:gocritic // Get proxy by ID to check auth token
|
||||
proxy, err := opts.DB.GetWorkspaceProxyByID(dbauthz.AsSystemRestricted(ctx), proxyID)
|
||||
if xerrors.Is(err, sql.ErrNoRows) {
|
||||
// Proxy IDs are public so we don't care about leaking them via
|
||||
// timing attacks.
|
||||
httpapi.Write(ctx, w, http.StatusUnauthorized, codersdk.Response{
|
||||
Message: "Invalid external proxy token",
|
||||
Detail: "Proxy not found.",
|
||||
})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
httpapi.InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
if proxy.Deleted {
|
||||
httpapi.Write(ctx, w, http.StatusUnauthorized, codersdk.Response{
|
||||
Message: "Invalid external proxy token",
|
||||
Detail: "Proxy has been deleted.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Do a subtle constant time comparison of the hash of the secret.
|
||||
hashedSecret := sha256.Sum256([]byte(secret))
|
||||
if subtle.ConstantTimeCompare(proxy.TokenHashedSecret, hashedSecret[:]) != 1 {
|
||||
httpapi.Write(ctx, w, http.StatusUnauthorized, codersdk.Response{
|
||||
Message: "Invalid external proxy token",
|
||||
Detail: "Invalid proxy token secret.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ctx = r.Context()
|
||||
ctx = context.WithValue(ctx, workspaceProxyContextKey{}, proxy)
|
||||
//nolint:gocritic // Workspace proxies have full permissions. The
|
||||
// workspace proxy auth middleware is not mounted to every route, so
|
||||
// they can still only access the routes that the middleware is
|
||||
// mounted to.
|
||||
ctx = dbauthz.AsSystemRestricted(ctx)
|
||||
subj, ok := dbauthz.ActorFromContext(ctx)
|
||||
if !ok {
|
||||
// This should never happen
|
||||
httpapi.InternalServerError(w, xerrors.New("developer error: ExtractWorkspaceProxy missing rbac actor"))
|
||||
return
|
||||
}
|
||||
// Use the same subject for the userAuthKey
|
||||
ctx = context.WithValue(ctx, userAuthKey{}, Authorization{
|
||||
Actor: subj,
|
||||
ActorName: "proxy_" + proxy.Name,
|
||||
})
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package httpmw_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/database/dbfake"
|
||||
"github.com/coder/coder/coderd/database/dbgen"
|
||||
"github.com/coder/coder/coderd/httpapi"
|
||||
"github.com/coder/coder/coderd/httpmw"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/cryptorand"
|
||||
)
|
||||
|
||||
func TestExtractWorkspaceProxy(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
successHandler := http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
// Only called if the API key passes through the handler.
|
||||
httpapi.Write(context.Background(), rw, http.StatusOK, codersdk.Response{
|
||||
Message: "It worked!",
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("NoHeader", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var (
|
||||
db = dbfake.New()
|
||||
r = httptest.NewRequest("GET", "/", nil)
|
||||
rw = httptest.NewRecorder()
|
||||
)
|
||||
httpmw.ExtractWorkspaceProxy(httpmw.ExtractWorkspaceProxyConfig{
|
||||
DB: db,
|
||||
})(successHandler).ServeHTTP(rw, r)
|
||||
res := rw.Result()
|
||||
defer res.Body.Close()
|
||||
require.Equal(t, http.StatusUnauthorized, res.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("InvalidFormat", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var (
|
||||
db = dbfake.New()
|
||||
r = httptest.NewRequest("GET", "/", nil)
|
||||
rw = httptest.NewRecorder()
|
||||
)
|
||||
r.Header.Set(httpmw.WorkspaceProxyAuthTokenHeader, "test:wow-hello")
|
||||
|
||||
httpmw.ExtractWorkspaceProxy(httpmw.ExtractWorkspaceProxyConfig{
|
||||
DB: db,
|
||||
})(successHandler).ServeHTTP(rw, r)
|
||||
res := rw.Result()
|
||||
defer res.Body.Close()
|
||||
require.Equal(t, http.StatusUnauthorized, res.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("InvalidID", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var (
|
||||
db = dbfake.New()
|
||||
r = httptest.NewRequest("GET", "/", nil)
|
||||
rw = httptest.NewRecorder()
|
||||
)
|
||||
r.Header.Set(httpmw.WorkspaceProxyAuthTokenHeader, "test:wow")
|
||||
|
||||
httpmw.ExtractWorkspaceProxy(httpmw.ExtractWorkspaceProxyConfig{
|
||||
DB: db,
|
||||
})(successHandler).ServeHTTP(rw, r)
|
||||
res := rw.Result()
|
||||
defer res.Body.Close()
|
||||
require.Equal(t, http.StatusUnauthorized, res.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("InvalidSecretLength", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var (
|
||||
db = dbfake.New()
|
||||
r = httptest.NewRequest("GET", "/", nil)
|
||||
rw = httptest.NewRecorder()
|
||||
)
|
||||
r.Header.Set(httpmw.WorkspaceProxyAuthTokenHeader, fmt.Sprintf("%s:%s", uuid.NewString(), "wow"))
|
||||
|
||||
httpmw.ExtractWorkspaceProxy(httpmw.ExtractWorkspaceProxyConfig{
|
||||
DB: db,
|
||||
})(successHandler).ServeHTTP(rw, r)
|
||||
res := rw.Result()
|
||||
defer res.Body.Close()
|
||||
require.Equal(t, http.StatusUnauthorized, res.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("NotFound", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var (
|
||||
db = dbfake.New()
|
||||
r = httptest.NewRequest("GET", "/", nil)
|
||||
rw = httptest.NewRecorder()
|
||||
)
|
||||
|
||||
secret, err := cryptorand.HexString(64)
|
||||
require.NoError(t, err)
|
||||
r.Header.Set(httpmw.WorkspaceProxyAuthTokenHeader, fmt.Sprintf("%s:%s", uuid.NewString(), secret))
|
||||
|
||||
httpmw.ExtractWorkspaceProxy(httpmw.ExtractWorkspaceProxyConfig{
|
||||
DB: db,
|
||||
})(successHandler).ServeHTTP(rw, r)
|
||||
res := rw.Result()
|
||||
defer res.Body.Close()
|
||||
require.Equal(t, http.StatusUnauthorized, res.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("InvalidSecret", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var (
|
||||
db = dbfake.New()
|
||||
r = httptest.NewRequest("GET", "/", nil)
|
||||
rw = httptest.NewRecorder()
|
||||
|
||||
proxy, _ = dbgen.WorkspaceProxy(t, db, database.WorkspaceProxy{})
|
||||
)
|
||||
|
||||
// Use a different secret so they don't match!
|
||||
secret, err := cryptorand.HexString(64)
|
||||
require.NoError(t, err)
|
||||
r.Header.Set(httpmw.WorkspaceProxyAuthTokenHeader, fmt.Sprintf("%s:%s", proxy.ID.String(), secret))
|
||||
|
||||
httpmw.ExtractWorkspaceProxy(httpmw.ExtractWorkspaceProxyConfig{
|
||||
DB: db,
|
||||
})(successHandler).ServeHTTP(rw, r)
|
||||
res := rw.Result()
|
||||
defer res.Body.Close()
|
||||
require.Equal(t, http.StatusUnauthorized, res.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("Valid", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var (
|
||||
db = dbfake.New()
|
||||
r = httptest.NewRequest("GET", "/", nil)
|
||||
rw = httptest.NewRecorder()
|
||||
|
||||
proxy, secret = dbgen.WorkspaceProxy(t, db, database.WorkspaceProxy{})
|
||||
)
|
||||
r.Header.Set(httpmw.WorkspaceProxyAuthTokenHeader, fmt.Sprintf("%s:%s", proxy.ID.String(), secret))
|
||||
|
||||
httpmw.ExtractWorkspaceProxy(httpmw.ExtractWorkspaceProxyConfig{
|
||||
DB: db,
|
||||
})(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
// Checks that it exists on the context!
|
||||
_ = httpmw.WorkspaceProxy(r)
|
||||
successHandler.ServeHTTP(rw, r)
|
||||
})).ServeHTTP(rw, r)
|
||||
res := rw.Result()
|
||||
defer res.Body.Close()
|
||||
require.Equal(t, http.StatusOK, res.StatusCode)
|
||||
})
|
||||
}
|
||||
@@ -1005,11 +1005,14 @@ func (api *API) workspaceAgentCoordinate(rw http.ResponseWriter, r *http.Request
|
||||
func (api *API) workspaceAgentClientCoordinate(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
// This route accepts user API key auth and workspace proxy auth. The moon actor has
|
||||
// full permissions so should be able to pass this authz check.
|
||||
workspace := httpmw.WorkspaceParam(r)
|
||||
if !api.Authorize(r, rbac.ActionCreate, workspace.ExecutionRBAC()) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
|
||||
// This is used by Enterprise code to control the functionality of this route.
|
||||
override := api.WorkspaceClientCoordinateOverride.Load()
|
||||
if override != nil {
|
||||
|
||||
+36
-21
@@ -1,11 +1,14 @@
|
||||
package coderd
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/httpapi"
|
||||
"github.com/coder/coder/coderd/httpmw"
|
||||
@@ -48,13 +51,6 @@ func (api *API) appHost(rw http.ResponseWriter, r *http.Request) {
|
||||
// @Router /applications/auth-redirect [get]
|
||||
func (api *API) workspaceApplicationAuth(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
if api.AppHostname == "" {
|
||||
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
|
||||
Message: "The server does not accept subdomain-based application requests.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
apiKey := httpmw.APIKey(r)
|
||||
if !api.Authorize(r, rbac.ActionCreate, apiKey) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
@@ -81,22 +77,41 @@ func (api *API) workspaceApplicationAuth(rw http.ResponseWriter, r *http.Request
|
||||
// security purposes.
|
||||
u.Scheme = api.AccessURL.Scheme
|
||||
|
||||
ok := false
|
||||
if api.AppHostnameRegex != nil {
|
||||
_, ok = httpapi.ExecuteHostnamePattern(api.AppHostnameRegex, u.Host)
|
||||
}
|
||||
|
||||
// Ensure that the redirect URI is a subdomain of api.Hostname and is a
|
||||
// valid app subdomain.
|
||||
subdomain, ok := httpapi.ExecuteHostnamePattern(api.AppHostnameRegex, u.Host)
|
||||
if !ok {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "The redirect_uri query parameter must be a valid app subdomain.",
|
||||
})
|
||||
return
|
||||
}
|
||||
_, err = httpapi.ParseSubdomainAppURL(subdomain)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "The redirect_uri query parameter must be a valid app subdomain.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
proxy, err := api.Database.GetWorkspaceProxyByHostname(ctx, u.Hostname())
|
||||
if xerrors.Is(err, sql.ErrNoRows) {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "The redirect_uri query parameter must be the primary wildcard app hostname, a workspace proxy access URL or a workspace proxy wildcard app hostname.",
|
||||
})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to get workspace proxy by redirect_uri.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
proxyURL, err := url.Parse(proxy.Url)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to parse workspace proxy URL.",
|
||||
Detail: xerrors.Errorf("parse proxy URL %q: %w", proxy.Url, err).Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Force the redirect URI to use the same scheme as the proxy access URL
|
||||
// for security purposes.
|
||||
u.Scheme = proxyURL.Scheme
|
||||
}
|
||||
|
||||
// Create the application_connect-scoped API key with the same lifetime as
|
||||
@@ -139,5 +154,5 @@ func (api *API) workspaceApplicationAuth(rw http.ResponseWriter, r *http.Request
|
||||
q := u.Query()
|
||||
q.Set(workspaceapps.SubdomainProxyAPIKeyParam, encryptedAPIKey)
|
||||
u.RawQuery = q.Encode()
|
||||
http.Redirect(rw, r, u.String(), http.StatusTemporaryRedirect)
|
||||
http.Redirect(rw, r, u.String(), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"net/http/cookiejar"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"path"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -24,6 +25,7 @@ import (
|
||||
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/coderd/rbac"
|
||||
"github.com/coder/coder/coderd/workspaceapps"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
@@ -31,16 +33,16 @@ import (
|
||||
// Run runs the entire workspace app test suite against deployments minted
|
||||
// by the provided factory.
|
||||
func Run(t *testing.T, factory DeploymentFactory) {
|
||||
setupProxyTest := func(t *testing.T, opts *DeploymentOptions) *AppDetails {
|
||||
setupProxyTest := func(t *testing.T, opts *DeploymentOptions) *Details {
|
||||
return setupProxyTestWithFactory(t, factory, opts)
|
||||
}
|
||||
|
||||
t.Run("ReconnectingPTY", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if runtime.GOOS == "windows" {
|
||||
// This might be our implementation, or ConPTY itself.
|
||||
// It's difficult to find extensive tests for it, so
|
||||
// it seems like it could be either.
|
||||
// This might be our implementation, or ConPTY itself. It's
|
||||
// difficult to find extensive tests for it, so it seems like it
|
||||
// could be either.
|
||||
t.Skip("ConPTY appears to be inconsistent on Windows.")
|
||||
}
|
||||
|
||||
@@ -51,9 +53,7 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
|
||||
// Run the test against the path app hostname since that's where the
|
||||
// reconnecting-pty proxy server we want to test is mounted.
|
||||
client := codersdk.New(appDetails.PathAppBaseURL)
|
||||
client.SetSessionToken(appDetails.Client.SessionToken())
|
||||
|
||||
client := appDetails.AppClient(t)
|
||||
conn, err := client.WorkspaceAgentReconnectingPTY(ctx, appDetails.Agent.ID, uuid.New(), 80, 80, "/bin/bash")
|
||||
require.NoError(t, err)
|
||||
defer conn.Close()
|
||||
@@ -115,7 +115,7 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, appDetails.PathAppURL(appDetails.OwnerApp).String(), nil)
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), http.MethodGet, appDetails.PathAppURL(appDetails.Apps.Owner).String(), nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
@@ -124,40 +124,79 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
require.Contains(t, string(body), "Path-based applications are disabled")
|
||||
})
|
||||
|
||||
t.Run("LoginWithoutAuth", func(t *testing.T) {
|
||||
t.Run("LoginWithoutAuthOnPrimary", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Clone the client to strip auth.
|
||||
unauthedClient := codersdk.New(appDetails.Client.URL)
|
||||
unauthedClient.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
if !appDetails.AppHostIsPrimary {
|
||||
t.Skip("This test only applies when testing apps on the primary.")
|
||||
}
|
||||
|
||||
unauthedClient := appDetails.AppClient(t)
|
||||
unauthedClient.SetSessionToken("")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
resp, err := requestWithRetries(ctx, t, unauthedClient, http.MethodGet, appDetails.PathAppURL(appDetails.OwnerApp).String(), nil)
|
||||
u := appDetails.PathAppURL(appDetails.Apps.Owner).String()
|
||||
resp, err := requestWithRetries(ctx, t, unauthedClient, http.MethodGet, u, nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
|
||||
require.Equal(t, http.StatusSeeOther, resp.StatusCode)
|
||||
loc, err := resp.Location()
|
||||
require.NoError(t, err)
|
||||
require.True(t, loc.Query().Has("message"))
|
||||
require.True(t, loc.Query().Has("redirect"))
|
||||
})
|
||||
|
||||
t.Run("NoAccessShould404", func(t *testing.T) {
|
||||
t.Run("LoginWithoutAuthOnProxy", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
userClient, _ := coderdtest.CreateAnotherUser(t, appDetails.Client, appDetails.FirstUser.OrganizationID, rbac.RoleMember())
|
||||
userClient.HTTPClient.CheckRedirect = appDetails.Client.HTTPClient.CheckRedirect
|
||||
userClient.HTTPClient.Transport = appDetails.Client.HTTPClient.Transport
|
||||
if appDetails.AppHostIsPrimary {
|
||||
t.Skip("This test only applies when testing apps on workspace proxies.")
|
||||
}
|
||||
|
||||
unauthedClient := appDetails.AppClient(t)
|
||||
unauthedClient.SetSessionToken("")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
resp, err := requestWithRetries(ctx, t, userClient, http.MethodGet, appDetails.PathAppURL(appDetails.OwnerApp).String(), nil)
|
||||
u := appDetails.PathAppURL(appDetails.Apps.Owner)
|
||||
resp, err := requestWithRetries(ctx, t, unauthedClient, http.MethodGet, u.String(), nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
require.Equal(t, http.StatusSeeOther, resp.StatusCode)
|
||||
loc, err := resp.Location()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, appDetails.SDKClient.URL.Host, loc.Host)
|
||||
require.Equal(t, "/api/v2/applications/auth-redirect", loc.Path)
|
||||
|
||||
redirectURIStr := loc.Query().Get("redirect_uri")
|
||||
require.NotEmpty(t, redirectURIStr)
|
||||
redirectURI, err := url.Parse(redirectURIStr)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, u.Scheme, redirectURI.Scheme)
|
||||
require.Equal(t, u.Host, redirectURI.Host)
|
||||
// TODO(@dean): I have no idea how but the trailing slash on this
|
||||
// request is getting stripped.
|
||||
require.Equal(t, u.Path, redirectURI.Path+"/")
|
||||
require.Equal(t, u.RawQuery, redirectURI.RawQuery)
|
||||
})
|
||||
|
||||
t.Run("NoAccessShould404", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
userClient, _ := coderdtest.CreateAnotherUser(t, appDetails.SDKClient, appDetails.FirstUser.OrganizationID, rbac.RoleMember())
|
||||
userAppClient := appDetails.AppClient(t)
|
||||
userAppClient.SetSessionToken(userClient.SessionToken())
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
resp, err := requestWithRetries(ctx, t, userAppClient, http.MethodGet, appDetails.PathAppURL(appDetails.Apps.Owner).String(), nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
@@ -169,9 +208,9 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
u := appDetails.PathAppURL(appDetails.OwnerApp)
|
||||
u := appDetails.PathAppURL(appDetails.Apps.Owner)
|
||||
u.Path = strings.TrimSuffix(u.Path, "/")
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, u.String(), nil)
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), http.MethodGet, u.String(), nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
|
||||
@@ -183,9 +222,9 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
u := appDetails.PathAppURL(appDetails.OwnerApp)
|
||||
u := appDetails.PathAppURL(appDetails.Apps.Owner)
|
||||
u.RawQuery = ""
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, u.String(), nil)
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), http.MethodGet, u.String(), nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
|
||||
@@ -200,8 +239,8 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
u := appDetails.PathAppURL(appDetails.OwnerApp)
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, u.String(), nil)
|
||||
u := appDetails.PathAppURL(appDetails.Apps.Owner)
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), http.MethodGet, u.String(), nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
@@ -220,9 +259,8 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
require.Equal(t, appTokenCookie.Path, u.Path, "incorrect path on app token cookie")
|
||||
|
||||
// Ensure the signed app token cookie is valid.
|
||||
appTokenClient := codersdk.New(appDetails.Client.URL)
|
||||
appTokenClient.HTTPClient.CheckRedirect = appDetails.Client.HTTPClient.CheckRedirect
|
||||
appTokenClient.HTTPClient.Transport = appDetails.Client.HTTPClient.Transport
|
||||
appTokenClient := appDetails.AppClient(t)
|
||||
appTokenClient.SetSessionToken("")
|
||||
appTokenClient.HTTPClient.Jar, err = cookiejar.New(nil)
|
||||
require.NoError(t, err)
|
||||
appTokenClient.HTTPClient.Jar.SetCookies(u, []*http.Cookie{appTokenCookie})
|
||||
@@ -242,10 +280,10 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
app := appDetails.OwnerApp
|
||||
app := appDetails.Apps.Owner
|
||||
app.Username = codersdk.Me
|
||||
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, appDetails.PathAppURL(app).String(), nil)
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), http.MethodGet, appDetails.PathAppURL(app).String(), nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
@@ -261,7 +299,7 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, appDetails.PathAppURL(appDetails.OwnerApp).String(), nil, func(r *http.Request) {
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), http.MethodGet, appDetails.PathAppURL(appDetails.Apps.Owner).String(), nil, func(r *http.Request) {
|
||||
r.Header.Set("Cf-Connecting-IP", "1.1.1.1")
|
||||
})
|
||||
require.NoError(t, err)
|
||||
@@ -279,7 +317,7 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
resp, err := appDetails.Client.Request(ctx, http.MethodGet, appDetails.PathAppURL(appDetails.FakeApp).String(), nil)
|
||||
resp, err := appDetails.AppClient(t).Request(ctx, http.MethodGet, appDetails.PathAppURL(appDetails.Apps.Fake).String(), nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusBadGateway, resp.StatusCode)
|
||||
@@ -291,7 +329,7 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
resp, err := appDetails.Client.Request(ctx, http.MethodGet, appDetails.PathAppURL(appDetails.PortApp).String(), nil)
|
||||
resp, err := appDetails.AppClient(t).Request(ctx, http.MethodGet, appDetails.PathAppURL(appDetails.Apps.Port).String(), nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
// TODO(@deansheather): This should be 400. There's a todo in the
|
||||
@@ -309,187 +347,186 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
|
||||
appDetails := setupProxyTest(t, nil)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
// Get the current user and API key.
|
||||
user, err := appDetails.Client.User(ctx, codersdk.Me)
|
||||
require.NoError(t, err)
|
||||
currentAPIKey, err := appDetails.Client.APIKeyByID(ctx, appDetails.FirstUser.UserID.String(), strings.Split(appDetails.Client.SessionToken(), "-")[0])
|
||||
require.NoError(t, err)
|
||||
|
||||
// Try to load the application without authentication.
|
||||
subdomain := fmt.Sprintf("%s--%s--%s--%s", proxyTestAppNameOwner, proxyTestAgentName, appDetails.Workspace.Name, user.Username)
|
||||
u, err := url.Parse(fmt.Sprintf("http://%s.%s/test", subdomain, proxyTestSubdomain))
|
||||
require.NoError(t, err)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
var resp *http.Response
|
||||
resp, err = doWithRetries(t, appDetails.Client, req)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
|
||||
// Check that the Location is correct.
|
||||
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
|
||||
gotLocation, err := resp.Location()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, appDetails.Client.URL.Host, gotLocation.Host)
|
||||
require.Equal(t, "/api/v2/applications/auth-redirect", gotLocation.Path)
|
||||
require.Equal(t, u.String(), gotLocation.Query().Get("redirect_uri"))
|
||||
|
||||
// Load the application auth-redirect endpoint.
|
||||
resp, err = requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, "/api/v2/applications/auth-redirect", nil, codersdk.WithQueryParam(
|
||||
"redirect_uri", u.String(),
|
||||
))
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
|
||||
gotLocation, err = resp.Location()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Copy the query parameters and then check equality.
|
||||
u.RawQuery = gotLocation.RawQuery
|
||||
require.Equal(t, u, gotLocation)
|
||||
|
||||
// Verify the API key is set.
|
||||
var encryptedAPIKey string
|
||||
for k, v := range gotLocation.Query() {
|
||||
// The query parameter may change dynamically in the future and is
|
||||
// not exported, so we just use a fuzzy check instead.
|
||||
if strings.Contains(k, "api_key") {
|
||||
encryptedAPIKey = v[0]
|
||||
}
|
||||
}
|
||||
require.NotEmpty(t, encryptedAPIKey, "no API key was set in the query parameters")
|
||||
|
||||
// Decrypt the API key by following the request.
|
||||
t.Log("navigating to: ", gotLocation.String())
|
||||
req, err = http.NewRequestWithContext(ctx, "GET", gotLocation.String(), nil)
|
||||
require.NoError(t, err)
|
||||
resp, err = doWithRetries(t, appDetails.Client, req)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
|
||||
cookies := resp.Cookies()
|
||||
require.Len(t, cookies, 1)
|
||||
apiKey := cookies[0].Value
|
||||
|
||||
// Fetch the API key.
|
||||
apiKeyInfo, err := appDetails.Client.APIKeyByID(ctx, appDetails.FirstUser.UserID.String(), strings.Split(apiKey, "-")[0])
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, user.ID, apiKeyInfo.UserID)
|
||||
require.Equal(t, codersdk.LoginTypePassword, apiKeyInfo.LoginType)
|
||||
require.WithinDuration(t, currentAPIKey.ExpiresAt, apiKeyInfo.ExpiresAt, 5*time.Second)
|
||||
require.EqualValues(t, currentAPIKey.LifetimeSeconds, apiKeyInfo.LifetimeSeconds)
|
||||
|
||||
// Verify the API key permissions
|
||||
appClient := codersdk.New(appDetails.Client.URL)
|
||||
appClient.SetSessionToken(apiKey)
|
||||
appClient.HTTPClient.CheckRedirect = appDetails.Client.HTTPClient.CheckRedirect
|
||||
appClient.HTTPClient.Transport = appDetails.Client.HTTPClient.Transport
|
||||
|
||||
var (
|
||||
canCreateApplicationConnect = "can-create-application_connect"
|
||||
canReadUserMe = "can-read-user-me"
|
||||
)
|
||||
authRes, err := appClient.AuthCheck(ctx, codersdk.AuthorizationRequest{
|
||||
Checks: map[string]codersdk.AuthorizationCheck{
|
||||
canCreateApplicationConnect: {
|
||||
Object: codersdk.AuthorizationObject{
|
||||
ResourceType: "application_connect",
|
||||
OwnerID: "me",
|
||||
OrganizationID: appDetails.FirstUser.OrganizationID.String(),
|
||||
},
|
||||
Action: "create",
|
||||
},
|
||||
canReadUserMe: {
|
||||
Object: codersdk.AuthorizationObject{
|
||||
ResourceType: "user",
|
||||
OwnerID: "me",
|
||||
ResourceID: appDetails.FirstUser.UserID.String(),
|
||||
},
|
||||
Action: "read",
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.True(t, authRes[canCreateApplicationConnect])
|
||||
require.False(t, authRes[canReadUserMe])
|
||||
|
||||
// Load the application page with the API key set.
|
||||
gotLocation, err = resp.Location()
|
||||
require.NoError(t, err)
|
||||
t.Log("navigating to: ", gotLocation.String())
|
||||
req, err = http.NewRequestWithContext(ctx, "GET", gotLocation.String(), nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Set(codersdk.SessionTokenHeader, apiKey)
|
||||
resp, err = doWithRetries(t, appDetails.Client, req)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("VerifyRedirectURI", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
appDetails := setupProxyTest(t, nil)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
redirectURI string
|
||||
status int
|
||||
messageContains string
|
||||
name string
|
||||
appURL *url.URL
|
||||
verifyCookie func(t *testing.T, c *http.Cookie)
|
||||
}{
|
||||
{
|
||||
name: "NoRedirectURI",
|
||||
redirectURI: "",
|
||||
status: http.StatusBadRequest,
|
||||
messageContains: "Missing redirect_uri query parameter",
|
||||
name: "Subdomain",
|
||||
appURL: appDetails.SubdomainAppURL(appDetails.Apps.Owner),
|
||||
verifyCookie: func(t *testing.T, c *http.Cookie) {
|
||||
// TODO(@dean): fix these asserts, they don't seem to
|
||||
// work. I wonder if Go strips the domain from the
|
||||
// cookie object if it's invalid or something.
|
||||
// domain := strings.SplitN(appDetails.Options.AppHost, ".", 2)
|
||||
// require.Equal(t, "."+domain[1], c.Domain, "incorrect domain on app token cookie")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "InvalidURI",
|
||||
redirectURI: "not a url",
|
||||
status: http.StatusBadRequest,
|
||||
messageContains: "Invalid redirect_uri query parameter",
|
||||
},
|
||||
{
|
||||
name: "NotMatchAppHostname",
|
||||
redirectURI: "https://app--agent--workspace--user.not-a-match.com",
|
||||
status: http.StatusBadRequest,
|
||||
messageContains: "The redirect_uri query parameter must be a valid app subdomain",
|
||||
},
|
||||
{
|
||||
name: "InvalidAppURL",
|
||||
redirectURI: "https://not-an-app." + proxyTestSubdomain,
|
||||
status: http.StatusBadRequest,
|
||||
messageContains: "The redirect_uri query parameter must be a valid app subdomain",
|
||||
name: "Path",
|
||||
appURL: appDetails.PathAppURL(appDetails.Apps.Owner),
|
||||
verifyCookie: func(t *testing.T, c *http.Cookie) {
|
||||
// TODO(@dean): fix these asserts, they don't seem to
|
||||
// work. I wonder if Go strips the domain from the
|
||||
// cookie object if it's invalid or something.
|
||||
// require.Equal(t, "", c.Domain, "incorrect domain on app token cookie")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
c := c
|
||||
|
||||
if c.name == "Path" && appDetails.AppHostIsPrimary {
|
||||
// Workspace application auth does not apply to path apps
|
||||
// served from the primary access URL as no smuggling needs
|
||||
// to take place (they're already logged in with a session
|
||||
// token).
|
||||
continue
|
||||
}
|
||||
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, "/api/v2/applications/auth-redirect", nil,
|
||||
codersdk.WithQueryParam("redirect_uri", c.redirectURI),
|
||||
)
|
||||
// Get the current user and API key.
|
||||
user, err := appDetails.SDKClient.User(ctx, codersdk.Me)
|
||||
require.NoError(t, err)
|
||||
currentAPIKey, err := appDetails.SDKClient.APIKeyByID(ctx, appDetails.FirstUser.UserID.String(), strings.Split(appDetails.SDKClient.SessionToken(), "-")[0])
|
||||
require.NoError(t, err)
|
||||
|
||||
appClient := appDetails.AppClient(t)
|
||||
appClient.SetSessionToken("")
|
||||
|
||||
// Try to load the application without authentication.
|
||||
u := c.appURL
|
||||
u.Path = path.Join(u.Path, "/test")
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
var resp *http.Response
|
||||
resp, err = doWithRetries(t, appClient, req)
|
||||
require.NoError(t, err)
|
||||
|
||||
if !assert.Equal(t, http.StatusSeeOther, resp.StatusCode) {
|
||||
dump, err := httputil.DumpResponse(resp, true)
|
||||
require.NoError(t, err)
|
||||
t.Log(string(dump))
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// Check that the Location is correct.
|
||||
gotLocation, err := resp.Location()
|
||||
require.NoError(t, err)
|
||||
// This should always redirect to the primary access URL.
|
||||
require.Equal(t, appDetails.SDKClient.URL.Host, gotLocation.Host)
|
||||
require.Equal(t, "/api/v2/applications/auth-redirect", gotLocation.Path)
|
||||
require.Equal(t, u.String(), gotLocation.Query().Get("redirect_uri"))
|
||||
|
||||
// Load the application auth-redirect endpoint.
|
||||
resp, err = requestWithRetries(ctx, t, appDetails.SDKClient, http.MethodGet, "/api/v2/applications/auth-redirect", nil, codersdk.WithQueryParam(
|
||||
"redirect_uri", u.String(),
|
||||
))
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
|
||||
require.Equal(t, http.StatusSeeOther, resp.StatusCode)
|
||||
gotLocation, err = resp.Location()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Copy the query parameters and then check equality.
|
||||
u.RawQuery = gotLocation.RawQuery
|
||||
require.Equal(t, u, gotLocation)
|
||||
|
||||
// Verify the API key is set.
|
||||
encryptedAPIKey := gotLocation.Query().Get(workspaceapps.SubdomainProxyAPIKeyParam)
|
||||
require.NotEmpty(t, encryptedAPIKey, "no API key was set in the query parameters")
|
||||
|
||||
// Decrypt the API key by following the request.
|
||||
t.Log("navigating to: ", gotLocation.String())
|
||||
req, err = http.NewRequestWithContext(ctx, "GET", gotLocation.String(), nil)
|
||||
require.NoError(t, err)
|
||||
resp, err = doWithRetries(t, appClient, req)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
require.Equal(t, http.StatusSeeOther, resp.StatusCode)
|
||||
|
||||
cookies := resp.Cookies()
|
||||
var cookie *http.Cookie
|
||||
for _, c := range cookies {
|
||||
if c.Name == codersdk.DevURLSessionTokenCookie {
|
||||
cookie = c
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNil(t, cookie, "no app session token cookie was set")
|
||||
c.verifyCookie(t, cookie)
|
||||
apiKey := cookie.Value
|
||||
|
||||
// Fetch the API key from the API.
|
||||
apiKeyInfo, err := appDetails.SDKClient.APIKeyByID(ctx, appDetails.FirstUser.UserID.String(), strings.Split(apiKey, "-")[0])
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, user.ID, apiKeyInfo.UserID)
|
||||
require.Equal(t, codersdk.LoginTypePassword, apiKeyInfo.LoginType)
|
||||
require.WithinDuration(t, currentAPIKey.ExpiresAt, apiKeyInfo.ExpiresAt, 5*time.Second)
|
||||
require.EqualValues(t, currentAPIKey.LifetimeSeconds, apiKeyInfo.LifetimeSeconds)
|
||||
|
||||
// Verify the API key permissions
|
||||
appTokenAPIClient := codersdk.New(appDetails.SDKClient.URL)
|
||||
appTokenAPIClient.SetSessionToken(apiKey)
|
||||
appTokenAPIClient.HTTPClient.CheckRedirect = appDetails.SDKClient.HTTPClient.CheckRedirect
|
||||
appTokenAPIClient.HTTPClient.Transport = appDetails.SDKClient.HTTPClient.Transport
|
||||
|
||||
var (
|
||||
canCreateApplicationConnect = "can-create-application_connect"
|
||||
canReadUserMe = "can-read-user-me"
|
||||
)
|
||||
authRes, err := appTokenAPIClient.AuthCheck(ctx, codersdk.AuthorizationRequest{
|
||||
Checks: map[string]codersdk.AuthorizationCheck{
|
||||
canCreateApplicationConnect: {
|
||||
Object: codersdk.AuthorizationObject{
|
||||
ResourceType: "application_connect",
|
||||
OwnerID: "me",
|
||||
OrganizationID: appDetails.FirstUser.OrganizationID.String(),
|
||||
},
|
||||
Action: "create",
|
||||
},
|
||||
canReadUserMe: {
|
||||
Object: codersdk.AuthorizationObject{
|
||||
ResourceType: "user",
|
||||
OwnerID: "me",
|
||||
ResourceID: appDetails.FirstUser.UserID.String(),
|
||||
},
|
||||
Action: "read",
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.True(t, authRes[canCreateApplicationConnect])
|
||||
require.False(t, authRes[canReadUserMe])
|
||||
|
||||
// Load the application page with the API key set.
|
||||
gotLocation, err = resp.Location()
|
||||
require.NoError(t, err)
|
||||
t.Log("navigating to: ", gotLocation.String())
|
||||
req, err = http.NewRequestWithContext(ctx, "GET", gotLocation.String(), nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Set(codersdk.SessionTokenHeader, apiKey)
|
||||
resp, err = doWithRetries(t, appClient, req)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// This test ensures that the subdomain handler does nothing if --app-hostname
|
||||
// is not set by the admin.
|
||||
// This test ensures that the subdomain handler does nothing if
|
||||
// --app-hostname is not set by the admin.
|
||||
t.Run("WorkspaceAppsProxySubdomainPassthrough", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -499,12 +536,17 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
DisableSubdomainApps: true,
|
||||
noWorkspace: true,
|
||||
})
|
||||
if !appDetails.AppHostIsPrimary {
|
||||
t.Skip("app hostname does not serve API")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
uri := fmt.Sprintf("http://app--agent--workspace--username.%s/api/v2/users/me", proxyTestSubdomain)
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, uri, nil)
|
||||
u := *appDetails.SDKClient.URL
|
||||
u.Host = "app--agent--workspace--username.test.coder.com"
|
||||
u.Path = "/api/v2/users/me"
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), http.MethodGet, u.String(), nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
@@ -535,7 +577,7 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
|
||||
host := strings.Replace(appDetails.Options.AppHost, "*", "not-an-app-subdomain", 1)
|
||||
uri := fmt.Sprintf("http://%s/api/v2/users/me", host)
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, uri, nil)
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), http.MethodGet, uri, nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
@@ -555,14 +597,14 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
t.Run("NoAccessShould401", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
userClient, _ := coderdtest.CreateAnotherUser(t, appDetails.Client, appDetails.FirstUser.OrganizationID, rbac.RoleMember())
|
||||
userClient.HTTPClient.CheckRedirect = appDetails.Client.HTTPClient.CheckRedirect
|
||||
userClient.HTTPClient.Transport = appDetails.Client.HTTPClient.Transport
|
||||
userClient, _ := coderdtest.CreateAnotherUser(t, appDetails.SDKClient, appDetails.FirstUser.OrganizationID, rbac.RoleMember())
|
||||
userAppClient := appDetails.AppClient(t)
|
||||
userAppClient.SetSessionToken(userClient.SessionToken())
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
resp, err := requestWithRetries(ctx, t, userClient, http.MethodGet, appDetails.SubdomainAppURL(appDetails.OwnerApp).String(), nil)
|
||||
resp, err := requestWithRetries(ctx, t, userAppClient, http.MethodGet, appDetails.SubdomainAppURL(appDetails.Apps.Owner).String(), nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
@@ -574,17 +616,17 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
u := appDetails.SubdomainAppURL(appDetails.OwnerApp)
|
||||
u := appDetails.SubdomainAppURL(appDetails.Apps.Owner)
|
||||
u.Path = ""
|
||||
u.RawQuery = ""
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, u.String(), nil)
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), http.MethodGet, u.String(), nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
|
||||
|
||||
loc, err := resp.Location()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, appDetails.SubdomainAppURL(appDetails.OwnerApp).Path, loc.Path)
|
||||
require.Equal(t, appDetails.SubdomainAppURL(appDetails.Apps.Owner).Path, loc.Path)
|
||||
})
|
||||
|
||||
t.Run("RedirectsWithQuery", func(t *testing.T) {
|
||||
@@ -593,16 +635,16 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
u := appDetails.SubdomainAppURL(appDetails.OwnerApp)
|
||||
u := appDetails.SubdomainAppURL(appDetails.Apps.Owner)
|
||||
u.RawQuery = ""
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, u.String(), nil)
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), http.MethodGet, u.String(), nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
|
||||
|
||||
loc, err := resp.Location()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, appDetails.SubdomainAppURL(appDetails.OwnerApp).RawQuery, loc.RawQuery)
|
||||
require.Equal(t, appDetails.SubdomainAppURL(appDetails.Apps.Owner).RawQuery, loc.RawQuery)
|
||||
})
|
||||
|
||||
t.Run("Proxies", func(t *testing.T) {
|
||||
@@ -611,8 +653,8 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
u := appDetails.SubdomainAppURL(appDetails.OwnerApp)
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, u.String(), nil)
|
||||
u := appDetails.SubdomainAppURL(appDetails.Apps.Owner)
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), http.MethodGet, u.String(), nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
@@ -630,10 +672,9 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
require.NotNil(t, appTokenCookie, "no signed token cookie in response")
|
||||
require.Equal(t, appTokenCookie.Path, "/", "incorrect path on signed token cookie")
|
||||
|
||||
// Ensure the session token cookie is valid.
|
||||
appTokenClient := codersdk.New(appDetails.Client.URL)
|
||||
appTokenClient.HTTPClient.CheckRedirect = appDetails.Client.HTTPClient.CheckRedirect
|
||||
appTokenClient.HTTPClient.Transport = appDetails.Client.HTTPClient.Transport
|
||||
// Ensure the signed app token cookie is valid.
|
||||
appTokenClient := appDetails.AppClient(t)
|
||||
appTokenClient.SetSessionToken("")
|
||||
appTokenClient.HTTPClient.Jar, err = cookiejar.New(nil)
|
||||
require.NoError(t, err)
|
||||
appTokenClient.HTTPClient.Jar.SetCookies(u, []*http.Cookie{appTokenCookie})
|
||||
@@ -653,7 +694,7 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, appDetails.SubdomainAppURL(appDetails.PortApp).String(), nil)
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), http.MethodGet, appDetails.SubdomainAppURL(appDetails.Apps.Port).String(), nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
@@ -668,7 +709,7 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
resp, err := appDetails.Client.Request(ctx, http.MethodGet, appDetails.SubdomainAppURL(appDetails.FakeApp).String(), nil)
|
||||
resp, err := appDetails.AppClient(t).Request(ctx, http.MethodGet, appDetails.SubdomainAppURL(appDetails.Apps.Fake).String(), nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusBadGateway, resp.StatusCode)
|
||||
@@ -680,9 +721,9 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
app := appDetails.PortApp
|
||||
app := appDetails.Apps.Port
|
||||
app.AppSlugOrPort = strconv.Itoa(codersdk.WorkspaceAgentMinimumListeningPort - 1)
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, appDetails.SubdomainAppURL(app).String(), nil)
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), http.MethodGet, appDetails.SubdomainAppURL(app).String(), nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
@@ -704,10 +745,10 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
u := appDetails.SubdomainAppURL(appDetails.OwnerApp)
|
||||
u := appDetails.SubdomainAppURL(appDetails.Apps.Owner)
|
||||
t.Logf("url: %s", u)
|
||||
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, u.String(), nil)
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), http.MethodGet, u.String(), nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
@@ -727,19 +768,19 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
u := appDetails.SubdomainAppURL(appDetails.OwnerApp)
|
||||
u := appDetails.SubdomainAppURL(appDetails.Apps.Owner)
|
||||
// Replace the -suffix with nothing.
|
||||
u.Host = strings.Replace(u.Host, "-suffix", "", 1)
|
||||
t.Logf("url: %s", u)
|
||||
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, u.String(), nil)
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), http.MethodGet, u.String(), nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
// It's probably rendering the dashboard, so only ensure that the body
|
||||
// doesn't match.
|
||||
// It's probably rendering the dashboard or a 404 page, so only
|
||||
// ensure that the body doesn't match.
|
||||
require.NotContains(t, string(body), proxyTestAppBody)
|
||||
})
|
||||
|
||||
@@ -749,12 +790,12 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
u := appDetails.SubdomainAppURL(appDetails.OwnerApp)
|
||||
u := appDetails.SubdomainAppURL(appDetails.Apps.Owner)
|
||||
// Replace the -suffix with something else.
|
||||
u.Host = strings.Replace(u.Host, "-suffix", "-not-suffix", 1)
|
||||
t.Logf("url: %s", u)
|
||||
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, u.String(), nil)
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), http.MethodGet, u.String(), nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
@@ -770,7 +811,7 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
t.Run("AppSharing", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
setup := func(t *testing.T, allowPathAppSharing, allowSiteOwnerAccess bool) (appDetails *AppDetails, workspace codersdk.Workspace, agnt codersdk.WorkspaceAgent, user codersdk.User, ownerClient *codersdk.Client, client *codersdk.Client, clientInOtherOrg *codersdk.Client, clientWithNoAuth *codersdk.Client) {
|
||||
setup := func(t *testing.T, allowPathAppSharing, allowSiteOwnerAccess bool) (appDetails *Details, workspace codersdk.Workspace, agnt codersdk.WorkspaceAgent, user codersdk.User, ownerClient *codersdk.Client, client *codersdk.Client, clientInOtherOrg *codersdk.Client, clientWithNoAuth *codersdk.Client) {
|
||||
//nolint:gosec
|
||||
const password = "SomeSecurePassword!"
|
||||
|
||||
@@ -786,7 +827,7 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
|
||||
// Create a template-admin user in the same org. We don't use an owner
|
||||
// since they have access to everything.
|
||||
ownerClient = appDetails.Client
|
||||
ownerClient = appDetails.SDKClient
|
||||
user, err := ownerClient.CreateUser(ctx, codersdk.CreateUserRequest{
|
||||
Email: "user@coder.com",
|
||||
Username: "user",
|
||||
@@ -814,7 +855,7 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
|
||||
// Create workspace.
|
||||
port := appServer(t)
|
||||
workspace, _ = createWorkspaceWithApps(t, client, user.OrganizationIDs[0], user, proxyTestSubdomainRaw, port)
|
||||
workspace, _ = createWorkspaceWithApps(t, client, user.OrganizationIDs[0], user, port)
|
||||
|
||||
// Verify that the apps have the correct sharing levels set.
|
||||
workspaceBuild, err := client.WorkspaceBuild(ctx, workspace.LatestBuild.ID)
|
||||
@@ -869,7 +910,7 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
return appDetails, workspace, agnt, user, ownerClient, client, clientInOtherOrg, clientWithNoAuth
|
||||
}
|
||||
|
||||
verifyAccess := func(t *testing.T, appDetails *AppDetails, isPathApp bool, username, workspaceName, agentName, appName string, client *codersdk.Client, shouldHaveAccess, shouldRedirectToLogin bool) {
|
||||
verifyAccess := func(t *testing.T, appDetails *Details, isPathApp bool, username, workspaceName, agentName, appName string, client *codersdk.Client, shouldHaveAccess, shouldRedirectToLogin bool) {
|
||||
t.Helper()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
@@ -877,29 +918,24 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
|
||||
// If the client has a session token, we also want to check that a
|
||||
// scoped key works.
|
||||
clients := []*codersdk.Client{client}
|
||||
sessionTokens := []string{client.SessionToken()}
|
||||
if client.SessionToken() != "" {
|
||||
token, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
|
||||
Scope: codersdk.APIKeyScopeApplicationConnect,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
scopedClient := codersdk.New(client.URL)
|
||||
scopedClient.SetSessionToken(token.Key)
|
||||
scopedClient.HTTPClient.CheckRedirect = client.HTTPClient.CheckRedirect
|
||||
scopedClient.HTTPClient.Transport = client.HTTPClient.Transport
|
||||
|
||||
clients = append(clients, scopedClient)
|
||||
sessionTokens = append(sessionTokens, token.Key)
|
||||
}
|
||||
|
||||
for i, client := range clients {
|
||||
for i, sessionToken := range sessionTokens {
|
||||
msg := fmt.Sprintf("client %d", i)
|
||||
|
||||
app := App{
|
||||
AppSlugOrPort: appName,
|
||||
AgentName: agentName,
|
||||
WorkspaceName: workspaceName,
|
||||
Username: username,
|
||||
WorkspaceName: workspaceName,
|
||||
AgentName: agentName,
|
||||
AppSlugOrPort: appName,
|
||||
Query: proxyTestAppQuery,
|
||||
}
|
||||
u := appDetails.SubdomainAppURL(app)
|
||||
@@ -907,6 +943,8 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
u = appDetails.PathAppURL(app)
|
||||
}
|
||||
|
||||
client := appDetails.AppClient(t)
|
||||
client.SetSessionToken(sessionToken)
|
||||
res, err := requestWithRetries(ctx, t, client, http.MethodGet, u.String(), nil)
|
||||
require.NoError(t, err, msg)
|
||||
|
||||
@@ -918,12 +956,12 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
|
||||
if !shouldHaveAccess {
|
||||
if shouldRedirectToLogin {
|
||||
assert.Equal(t, http.StatusTemporaryRedirect, res.StatusCode, "should not have access, expected temporary redirect. "+msg)
|
||||
assert.Equal(t, http.StatusSeeOther, res.StatusCode, "should not have access, expected See Other redirect. "+msg)
|
||||
location, err := res.Location()
|
||||
require.NoError(t, err, msg)
|
||||
|
||||
expectedPath := "/login"
|
||||
if !isPathApp {
|
||||
if !isPathApp || !appDetails.AppHostIsPrimary {
|
||||
expectedPath = "/api/v2/applications/auth-redirect"
|
||||
}
|
||||
assert.Equal(t, expectedPath, location.Path, "should not have access, expected redirect to applicable login endpoint. "+msg)
|
||||
@@ -1103,11 +1141,11 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
}{
|
||||
{
|
||||
name: "ProxyPath",
|
||||
u: appDetails.PathAppURL(appDetails.OwnerApp),
|
||||
u: appDetails.PathAppURL(appDetails.Apps.Owner),
|
||||
},
|
||||
{
|
||||
name: "ProxySubdomain",
|
||||
u: appDetails.SubdomainAppURL(appDetails.OwnerApp),
|
||||
u: appDetails.SubdomainAppURL(appDetails.Apps.Owner),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1132,9 +1170,9 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
// server.
|
||||
secWebSocketKey := "test-dean-was-here"
|
||||
req.Header["Sec-WebSocket-Key"] = []string{secWebSocketKey}
|
||||
req.Header.Set(codersdk.SessionTokenHeader, appDetails.SDKClient.SessionToken())
|
||||
|
||||
req.Header.Set(codersdk.SessionTokenHeader, appDetails.Client.SessionToken())
|
||||
resp, err := doWithRetries(t, appDetails.Client, req)
|
||||
resp, err := doWithRetries(t, appDetails.AppClient(t), req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
|
||||
@@ -58,10 +58,15 @@ type DeploymentOptions struct {
|
||||
type Deployment struct {
|
||||
Options *DeploymentOptions
|
||||
|
||||
// Client should be logged in as the admin user.
|
||||
Client *codersdk.Client
|
||||
// SDKClient should be logged in as the admin user.
|
||||
SDKClient *codersdk.Client
|
||||
FirstUser codersdk.CreateFirstUserResponse
|
||||
PathAppBaseURL *url.URL
|
||||
|
||||
// AppHostIsPrimary is true if the app host is also the primary coder API
|
||||
// server. This disables any tests that test API passthrough or rely on the
|
||||
// app server not being the API server.
|
||||
AppHostIsPrimary bool
|
||||
}
|
||||
|
||||
// DeploymentFactory generates a deployment with an API client, a path base URL,
|
||||
@@ -83,8 +88,8 @@ type App struct {
|
||||
Query string
|
||||
}
|
||||
|
||||
// AppDetails are the full test details returned from setupProxyTestWithFactory.
|
||||
type AppDetails struct {
|
||||
// Details are the full test details returned from setupProxyTestWithFactory.
|
||||
type Details struct {
|
||||
*Deployment
|
||||
|
||||
Me codersdk.User
|
||||
@@ -96,15 +101,33 @@ type AppDetails struct {
|
||||
Agent *codersdk.WorkspaceAgent
|
||||
AppPort uint16
|
||||
|
||||
FakeApp App
|
||||
OwnerApp App
|
||||
AuthenticatedApp App
|
||||
PublicApp App
|
||||
PortApp App
|
||||
Apps struct {
|
||||
Fake App
|
||||
Owner App
|
||||
Authenticated App
|
||||
Public App
|
||||
Port App
|
||||
}
|
||||
}
|
||||
|
||||
// AppClient returns a *codersdk.Client that will route all requests to the
|
||||
// app server. API requests will fail with this client. Any redirect responses
|
||||
// are not followed by default.
|
||||
//
|
||||
// The client is authenticated as the first user by default.
|
||||
func (d *Details) AppClient(t *testing.T) *codersdk.Client {
|
||||
client := codersdk.New(d.PathAppBaseURL)
|
||||
client.SetSessionToken(d.SDKClient.SessionToken())
|
||||
forceURLTransport(t, client)
|
||||
client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
// PathAppURL returns the URL for the given path app.
|
||||
func (d *AppDetails) PathAppURL(app App) *url.URL {
|
||||
func (d *Details) PathAppURL(app App) *url.URL {
|
||||
appPath := fmt.Sprintf("/@%s/%s/apps/%s", app.Username, app.WorkspaceName, app.AppSlugOrPort)
|
||||
|
||||
u := *d.PathAppBaseURL
|
||||
@@ -115,11 +138,7 @@ func (d *AppDetails) PathAppURL(app App) *url.URL {
|
||||
}
|
||||
|
||||
// SubdomainAppURL returns the URL for the given subdomain app.
|
||||
func (d *AppDetails) SubdomainAppURL(app App) *url.URL {
|
||||
if d.Options.DisableSubdomainApps || d.Options.AppHost == "" {
|
||||
panic("subdomain apps are disabled")
|
||||
}
|
||||
|
||||
func (d *Details) SubdomainAppURL(app App) *url.URL {
|
||||
host := fmt.Sprintf("%s--%s--%s--%s", app.AppSlugOrPort, app.AgentName, app.WorkspaceName, app.Username)
|
||||
|
||||
u := *d.PathAppBaseURL
|
||||
@@ -135,7 +154,7 @@ func (d *AppDetails) SubdomainAppURL(app App) *url.URL {
|
||||
// 3. Create a template version, template and workspace with many apps.
|
||||
// 4. Start a workspace agent.
|
||||
// 5. Returns details about the deployment and its apps.
|
||||
func setupProxyTestWithFactory(t *testing.T, factory DeploymentFactory, opts *DeploymentOptions) *AppDetails {
|
||||
func setupProxyTestWithFactory(t *testing.T, factory DeploymentFactory, opts *DeploymentOptions) *Details {
|
||||
if opts == nil {
|
||||
opts = &DeploymentOptions{}
|
||||
}
|
||||
@@ -150,19 +169,19 @@ func setupProxyTestWithFactory(t *testing.T, factory DeploymentFactory, opts *De
|
||||
|
||||
// Configure the HTTP client to not follow redirects and to route all
|
||||
// requests regardless of hostname to the coderd test server.
|
||||
deployment.Client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
deployment.SDKClient.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
forceURLTransport(t, deployment.Client)
|
||||
forceURLTransport(t, deployment.SDKClient)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium)
|
||||
defer cancel()
|
||||
|
||||
me, err := deployment.Client.User(ctx, codersdk.Me)
|
||||
me, err := deployment.SDKClient.User(ctx, codersdk.Me)
|
||||
require.NoError(t, err)
|
||||
|
||||
if opts.noWorkspace {
|
||||
return &AppDetails{
|
||||
return &Details{
|
||||
Deployment: deployment,
|
||||
Me: me,
|
||||
}
|
||||
@@ -171,49 +190,51 @@ func setupProxyTestWithFactory(t *testing.T, factory DeploymentFactory, opts *De
|
||||
if opts.port == 0 {
|
||||
opts.port = appServer(t)
|
||||
}
|
||||
workspace, agnt := createWorkspaceWithApps(t, deployment.Client, deployment.FirstUser.OrganizationID, me, opts.AppHost, opts.port)
|
||||
workspace, agnt := createWorkspaceWithApps(t, deployment.SDKClient, deployment.FirstUser.OrganizationID, me, opts.port)
|
||||
|
||||
return &AppDetails{
|
||||
details := &Details{
|
||||
Deployment: deployment,
|
||||
Me: me,
|
||||
Workspace: &workspace,
|
||||
Agent: &agnt,
|
||||
AppPort: opts.port,
|
||||
|
||||
FakeApp: App{
|
||||
Username: me.Username,
|
||||
WorkspaceName: workspace.Name,
|
||||
AgentName: agnt.Name,
|
||||
AppSlugOrPort: proxyTestAppNameFake,
|
||||
},
|
||||
OwnerApp: App{
|
||||
Username: me.Username,
|
||||
WorkspaceName: workspace.Name,
|
||||
AgentName: agnt.Name,
|
||||
AppSlugOrPort: proxyTestAppNameOwner,
|
||||
Query: proxyTestAppQuery,
|
||||
},
|
||||
AuthenticatedApp: App{
|
||||
Username: me.Username,
|
||||
WorkspaceName: workspace.Name,
|
||||
AgentName: agnt.Name,
|
||||
AppSlugOrPort: proxyTestAppNameAuthenticated,
|
||||
Query: proxyTestAppQuery,
|
||||
},
|
||||
PublicApp: App{
|
||||
Username: me.Username,
|
||||
WorkspaceName: workspace.Name,
|
||||
AgentName: agnt.Name,
|
||||
AppSlugOrPort: proxyTestAppNamePublic,
|
||||
Query: proxyTestAppQuery,
|
||||
},
|
||||
PortApp: App{
|
||||
Username: me.Username,
|
||||
WorkspaceName: workspace.Name,
|
||||
AgentName: agnt.Name,
|
||||
AppSlugOrPort: strconv.Itoa(int(opts.port)),
|
||||
},
|
||||
}
|
||||
|
||||
details.Apps.Fake = App{
|
||||
Username: me.Username,
|
||||
WorkspaceName: workspace.Name,
|
||||
AgentName: agnt.Name,
|
||||
AppSlugOrPort: proxyTestAppNameFake,
|
||||
}
|
||||
details.Apps.Owner = App{
|
||||
Username: me.Username,
|
||||
WorkspaceName: workspace.Name,
|
||||
AgentName: agnt.Name,
|
||||
AppSlugOrPort: proxyTestAppNameOwner,
|
||||
Query: proxyTestAppQuery,
|
||||
}
|
||||
details.Apps.Authenticated = App{
|
||||
Username: me.Username,
|
||||
WorkspaceName: workspace.Name,
|
||||
AgentName: agnt.Name,
|
||||
AppSlugOrPort: proxyTestAppNameAuthenticated,
|
||||
Query: proxyTestAppQuery,
|
||||
}
|
||||
details.Apps.Public = App{
|
||||
Username: me.Username,
|
||||
WorkspaceName: workspace.Name,
|
||||
AgentName: agnt.Name,
|
||||
AppSlugOrPort: proxyTestAppNamePublic,
|
||||
Query: proxyTestAppQuery,
|
||||
}
|
||||
details.Apps.Port = App{
|
||||
Username: me.Username,
|
||||
WorkspaceName: workspace.Name,
|
||||
AgentName: agnt.Name,
|
||||
AppSlugOrPort: strconv.Itoa(int(opts.port)),
|
||||
}
|
||||
|
||||
return details
|
||||
}
|
||||
|
||||
func appServer(t *testing.T) uint16 {
|
||||
@@ -259,7 +280,7 @@ func appServer(t *testing.T) uint16 {
|
||||
return uint16(tcpAddr.Port)
|
||||
}
|
||||
|
||||
func createWorkspaceWithApps(t *testing.T, client *codersdk.Client, orgID uuid.UUID, me codersdk.User, appHost string, port uint16, workspaceMutators ...func(*codersdk.CreateWorkspaceRequest)) (codersdk.Workspace, codersdk.WorkspaceAgent) {
|
||||
func createWorkspaceWithApps(t *testing.T, client *codersdk.Client, orgID uuid.UUID, me codersdk.User, port uint16, workspaceMutators ...func(*codersdk.CreateWorkspaceRequest)) (codersdk.Workspace, codersdk.WorkspaceAgent) {
|
||||
authToken := uuid.NewString()
|
||||
|
||||
appURL := fmt.Sprintf("http://127.0.0.1:%d?%s", port, proxyTestAppQuery)
|
||||
@@ -318,7 +339,18 @@ func createWorkspaceWithApps(t *testing.T, client *codersdk.Client, orgID uuid.U
|
||||
|
||||
agentClient := agentsdk.New(client.URL)
|
||||
agentClient.SetSessionToken(authToken)
|
||||
if appHost != "" {
|
||||
|
||||
// TODO (@dean): currently, the primary app host is used when generating
|
||||
// the port URL we tell the agent to use. We don't have any plans to change
|
||||
// that until we let templates pick which proxy they want to use in the
|
||||
// terraform.
|
||||
//
|
||||
// This means that all port URLs generated in code-server etc. will be sent
|
||||
// to the primary.
|
||||
appHostCtx := testutil.Context(t, testutil.WaitLong)
|
||||
primaryAppHost, err := client.AppHost(appHostCtx)
|
||||
require.NoError(t, err)
|
||||
if primaryAppHost.Host != "" {
|
||||
manifest, err := agentClient.Manifest(context.Background())
|
||||
require.NoError(t, err)
|
||||
proxyURL := fmt.Sprintf(
|
||||
@@ -326,11 +358,8 @@ func createWorkspaceWithApps(t *testing.T, client *codersdk.Client, orgID uuid.U
|
||||
proxyTestAgentName,
|
||||
workspace.Name,
|
||||
me.Username,
|
||||
strings.ReplaceAll(appHost, "*", ""),
|
||||
strings.ReplaceAll(primaryAppHost.Host, "*", ""),
|
||||
)
|
||||
if client.URL.Port() != "" {
|
||||
proxyURL += fmt.Sprintf(":%s", client.URL.Port())
|
||||
}
|
||||
require.Equal(t, proxyURL, manifest.VSCodePortProxyURI)
|
||||
}
|
||||
agentCloser := agent.New(agent.Options{
|
||||
@@ -386,7 +415,7 @@ func requestWithRetries(ctx context.Context, t require.TestingT, client *codersd
|
||||
}
|
||||
|
||||
// forceURLTransport forces the client to route all requests to the client's
|
||||
// configured URL host regardless of hostname.
|
||||
// configured URLs host regardless of hostname.
|
||||
func forceURLTransport(t *testing.T, client *codersdk.Client) {
|
||||
defaultTransport, ok := http.DefaultTransport.(*http.Transport)
|
||||
require.True(t, ok)
|
||||
|
||||
+80
-60
@@ -6,12 +6,13 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/coderd/httpapi"
|
||||
@@ -25,8 +26,8 @@ import (
|
||||
type DBTokenProvider struct {
|
||||
Logger slog.Logger
|
||||
|
||||
// AccessURL is the main dashboard access URL for error pages.
|
||||
AccessURL *url.URL
|
||||
// DashboardURL is the main dashboard access URL for error pages.
|
||||
DashboardURL *url.URL
|
||||
Authorizer rbac.Authorizer
|
||||
Database database.Store
|
||||
DeploymentValues *codersdk.DeploymentValues
|
||||
@@ -44,7 +45,7 @@ func NewDBTokenProvider(log slog.Logger, accessURL *url.URL, authz rbac.Authoriz
|
||||
|
||||
return &DBTokenProvider{
|
||||
Logger: log,
|
||||
AccessURL: accessURL,
|
||||
DashboardURL: accessURL,
|
||||
Authorizer: authz,
|
||||
Database: db,
|
||||
DeploymentValues: cfg,
|
||||
@@ -54,29 +55,11 @@ func NewDBTokenProvider(log slog.Logger, accessURL *url.URL, authz rbac.Authoriz
|
||||
}
|
||||
}
|
||||
|
||||
func (p *DBTokenProvider) TokenFromRequest(r *http.Request) (*SignedToken, bool) {
|
||||
// Get the existing token from the request.
|
||||
tokenCookie, err := r.Cookie(codersdk.DevURLSignedAppTokenCookie)
|
||||
if err == nil {
|
||||
token, err := p.SigningKey.VerifySignedToken(tokenCookie.Value)
|
||||
if err == nil {
|
||||
req := token.Request.Normalize()
|
||||
err := req.Validate()
|
||||
if err == nil {
|
||||
// The request has a valid signed app token, which is a valid
|
||||
// token signed by us. The caller must check that it matches
|
||||
// the request.
|
||||
return &token, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, false
|
||||
func (p *DBTokenProvider) FromRequest(r *http.Request) (*SignedToken, bool) {
|
||||
return FromRequest(r, p.SigningKey)
|
||||
}
|
||||
|
||||
// ResolveRequest takes an app request, checks if it's valid and authenticated,
|
||||
// and returns a token with details about the app.
|
||||
func (p *DBTokenProvider) CreateToken(ctx context.Context, rw http.ResponseWriter, r *http.Request, appReq Request) (*SignedToken, string, bool) {
|
||||
func (p *DBTokenProvider) Issue(ctx context.Context, rw http.ResponseWriter, r *http.Request, issueReq IssueTokenRequest) (*SignedToken, string, bool) {
|
||||
// nolint:gocritic // We need to make a number of database calls. Setting a system context here
|
||||
// // is simpler than calling dbauthz.AsSystemRestricted on every call.
|
||||
// // dangerousSystemCtx is only used for database calls. The actual authentication
|
||||
@@ -84,10 +67,10 @@ func (p *DBTokenProvider) CreateToken(ctx context.Context, rw http.ResponseWrite
|
||||
// // permissions.
|
||||
dangerousSystemCtx := dbauthz.AsSystemRestricted(ctx)
|
||||
|
||||
appReq = appReq.Normalize()
|
||||
appReq := issueReq.AppRequest.Normalize()
|
||||
err := appReq.Validate()
|
||||
if err != nil {
|
||||
WriteWorkspaceApp500(p.Logger, p.AccessURL, rw, r, &appReq, err, "invalid app request")
|
||||
WriteWorkspaceApp500(p.Logger, p.DashboardURL, rw, r, &appReq, err, "invalid app request")
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
@@ -102,11 +85,13 @@ func (p *DBTokenProvider) CreateToken(ctx context.Context, rw http.ResponseWrite
|
||||
OAuth2Configs: p.OAuth2Configs,
|
||||
RedirectToLogin: false,
|
||||
DisableSessionExpiryRefresh: p.DeploymentValues.DisableSessionExpiryRefresh.Value(),
|
||||
// Optional is true to allow for public apps. If an authorization check
|
||||
// fails and the user is not authenticated, they will be redirected to
|
||||
// the login page using code below (not the redirect from the
|
||||
// middleware itself).
|
||||
// Optional is true to allow for public apps. If the authorization check
|
||||
// (later on) fails and the user is not authenticated, they will be
|
||||
// redirected to the login page or app auth endpoint using code below.
|
||||
Optional: true,
|
||||
SessionTokenFunc: func(r *http.Request) string {
|
||||
return issueReq.SessionToken
|
||||
},
|
||||
})
|
||||
if !ok {
|
||||
return nil, "", false
|
||||
@@ -115,75 +100,110 @@ func (p *DBTokenProvider) CreateToken(ctx context.Context, rw http.ResponseWrite
|
||||
// Lookup workspace app details from DB.
|
||||
dbReq, err := appReq.getDatabase(dangerousSystemCtx, p.Database)
|
||||
if xerrors.Is(err, sql.ErrNoRows) {
|
||||
WriteWorkspaceApp404(p.Logger, p.AccessURL, rw, r, &appReq, err.Error())
|
||||
WriteWorkspaceApp404(p.Logger, p.DashboardURL, rw, r, &appReq, err.Error())
|
||||
return nil, "", false
|
||||
} else if err != nil {
|
||||
WriteWorkspaceApp500(p.Logger, p.AccessURL, rw, r, &appReq, err, "get app details from database")
|
||||
WriteWorkspaceApp500(p.Logger, p.DashboardURL, rw, r, &appReq, err, "get app details from database")
|
||||
return nil, "", false
|
||||
}
|
||||
token.UserID = dbReq.User.ID
|
||||
token.WorkspaceID = dbReq.Workspace.ID
|
||||
token.AgentID = dbReq.Agent.ID
|
||||
token.AppURL = dbReq.AppURL
|
||||
if dbReq.AppURL != nil {
|
||||
token.AppURL = dbReq.AppURL.String()
|
||||
}
|
||||
|
||||
// Verify the user has access to the app.
|
||||
authed, err := p.authorizeRequest(r.Context(), authz, dbReq)
|
||||
if err != nil {
|
||||
WriteWorkspaceApp500(p.Logger, p.AccessURL, rw, r, &appReq, err, "verify authz")
|
||||
WriteWorkspaceApp500(p.Logger, p.DashboardURL, rw, r, &appReq, err, "verify authz")
|
||||
return nil, "", false
|
||||
}
|
||||
if !authed {
|
||||
if apiKey != nil {
|
||||
// The request has a valid API key but insufficient permissions.
|
||||
WriteWorkspaceApp404(p.Logger, p.AccessURL, rw, r, &appReq, "insufficient permissions")
|
||||
WriteWorkspaceApp404(p.Logger, p.DashboardURL, rw, r, &appReq, "insufficient permissions")
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
// Redirect to login as they don't have permission to access the app
|
||||
// and they aren't signed in.
|
||||
switch appReq.AccessMethod {
|
||||
case AccessMethodPath:
|
||||
// TODO(@deansheather): this doesn't work on moons so will need to
|
||||
// be updated to include the access URL as a param
|
||||
httpmw.RedirectToLogin(rw, r, httpmw.SignedOutErrorMessage)
|
||||
case AccessMethodSubdomain:
|
||||
// Redirect to the app auth redirect endpoint with a valid redirect
|
||||
// URI.
|
||||
redirectURI := *r.URL
|
||||
redirectURI.Scheme = p.AccessURL.Scheme
|
||||
redirectURI.Host = httpapi.RequestHost(r)
|
||||
|
||||
u := *p.AccessURL
|
||||
u.Path = "/api/v2/applications/auth-redirect"
|
||||
q := u.Query()
|
||||
q.Add(RedirectURIQueryParam, redirectURI.String())
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
http.Redirect(rw, r, u.String(), http.StatusTemporaryRedirect)
|
||||
case AccessMethodTerminal:
|
||||
// Return an error.
|
||||
// We don't support login redirects for the terminal since it's a
|
||||
// WebSocket endpoint and redirects won't work. The token must be
|
||||
// specified as a query parameter.
|
||||
if appReq.AccessMethod == AccessMethodTerminal {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
appBaseURL, err := issueReq.AppBaseURL()
|
||||
if err != nil {
|
||||
WriteWorkspaceApp500(p.Logger, p.DashboardURL, rw, r, &appReq, err, "get app base URL")
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
// If the app is a path app and it's on the same host as the dashboard
|
||||
// access URL, then we need to redirect to login using the standard
|
||||
// login redirect function.
|
||||
if appReq.AccessMethod == AccessMethodPath && appBaseURL.Host == p.DashboardURL.Host {
|
||||
httpmw.RedirectToLogin(rw, r, p.DashboardURL, httpmw.SignedOutErrorMessage)
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
// Otherwise, we need to redirect to the app auth endpoint, which will
|
||||
// redirect back to the app (with an encrypted API key) after the user
|
||||
// has logged in.
|
||||
//
|
||||
// TODO: We should just make this a "BrowserURL" field on the issue struct. Then
|
||||
// we can remove this logic and just defer to that. It can be set closer to the
|
||||
// actual initial request that makes the IssueTokenRequest. Eg the external moon.
|
||||
// This would replace RawQuery and AppPath fields.
|
||||
redirectURI := *appBaseURL
|
||||
if dbReq.AppURL != nil {
|
||||
// Just use the user's current path and query if set.
|
||||
if issueReq.AppPath != "" {
|
||||
redirectURI.Path = path.Join(redirectURI.Path, issueReq.AppPath)
|
||||
} else if !strings.HasSuffix(redirectURI.Path, "/") {
|
||||
redirectURI.Path += "/"
|
||||
}
|
||||
q := issueReq.AppQuery
|
||||
if q != "" && dbReq.AppURL.RawQuery != "" {
|
||||
q = dbReq.AppURL.RawQuery
|
||||
}
|
||||
redirectURI.RawQuery = q
|
||||
}
|
||||
|
||||
// This endpoint accepts redirect URIs from the primary app wildcard
|
||||
// host, proxy access URLs and proxy wildcard app hosts. It does not
|
||||
// accept redirect URIs from the primary access URL or any other host.
|
||||
u := *p.DashboardURL
|
||||
u.Path = "/api/v2/applications/auth-redirect"
|
||||
q := u.Query()
|
||||
q.Add(RedirectURIQueryParam, redirectURI.String())
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
http.Redirect(rw, r, u.String(), http.StatusSeeOther)
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
// Check that the agent is online.
|
||||
agentStatus := dbReq.Agent.Status(p.WorkspaceAgentInactiveTimeout)
|
||||
if agentStatus.Status != database.WorkspaceAgentStatusConnected {
|
||||
WriteWorkspaceAppOffline(p.Logger, p.AccessURL, rw, r, &appReq, fmt.Sprintf("Agent state is %q, not %q", agentStatus.Status, database.WorkspaceAgentStatusConnected))
|
||||
WriteWorkspaceAppOffline(p.Logger, p.DashboardURL, rw, r, &appReq, fmt.Sprintf("Agent state is %q, not %q", agentStatus.Status, database.WorkspaceAgentStatusConnected))
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
// Check that the app is healthy.
|
||||
if dbReq.AppHealth != "" && dbReq.AppHealth != database.WorkspaceAppHealthDisabled && dbReq.AppHealth != database.WorkspaceAppHealthHealthy {
|
||||
WriteWorkspaceAppOffline(p.Logger, p.AccessURL, rw, r, &appReq, fmt.Sprintf("App health is %q, not %q", dbReq.AppHealth, database.WorkspaceAppHealthHealthy))
|
||||
WriteWorkspaceAppOffline(p.Logger, p.DashboardURL, rw, r, &appReq, fmt.Sprintf("App health is %q, not %q", dbReq.AppHealth, database.WorkspaceAppHealthHealthy))
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
// As a sanity check, ensure the token we just made is valid for this
|
||||
// request.
|
||||
if !token.MatchesRequest(appReq) {
|
||||
WriteWorkspaceApp500(p.Logger, p.AccessURL, rw, r, &appReq, nil, "fresh token does not match request")
|
||||
WriteWorkspaceApp500(p.Logger, p.DashboardURL, rw, r, &appReq, nil, "fresh token does not match request")
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
@@ -191,7 +211,7 @@ func (p *DBTokenProvider) CreateToken(ctx context.Context, rw http.ResponseWrite
|
||||
token.Expiry = time.Now().Add(DefaultTokenExpiry)
|
||||
tokenStr, err := p.SigningKey.SignToken(token)
|
||||
if err != nil {
|
||||
WriteWorkspaceApp500(p.Logger, p.AccessURL, rw, r, &appReq, err, "generate token")
|
||||
WriteWorkspaceApp500(p.Logger, p.DashboardURL, rw, r, &appReq, err, "generate token")
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
|
||||
+128
-17
@@ -63,6 +63,7 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
deploymentValues.Dangerous.AllowPathAppSiteOwnerAccess = true
|
||||
|
||||
client, closer, api := coderdtest.NewWithAPI(t, &coderdtest.Options{
|
||||
AppHostname: "*.test.coder.com",
|
||||
DeploymentValues: deploymentValues,
|
||||
IncludeProvisionerDaemon: true,
|
||||
AgentStatsRefreshInterval: time.Millisecond * 100,
|
||||
@@ -236,7 +237,14 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
|
||||
|
||||
// Try resolving the request without a token.
|
||||
token, ok := workspaceapps.ResolveRequest(api.Logger, api.AccessURL, api.WorkspaceAppsProvider, rw, r, req)
|
||||
token, ok := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{
|
||||
Logger: api.Logger,
|
||||
SignedTokenProvider: api.WorkspaceAppsProvider,
|
||||
DashboardURL: api.AccessURL,
|
||||
PathAppBaseURL: api.AccessURL,
|
||||
AppHostname: api.AppHostname,
|
||||
AppRequest: req,
|
||||
})
|
||||
w := rw.Result()
|
||||
if !assert.True(t, ok) {
|
||||
dump, err := httputil.DumpResponse(w, true)
|
||||
@@ -275,7 +283,14 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
r = httptest.NewRequest("GET", "/app", nil)
|
||||
r.AddCookie(cookie)
|
||||
|
||||
secondToken, ok := workspaceapps.ResolveRequest(api.Logger, api.AccessURL, api.WorkspaceAppsProvider, rw, r, req)
|
||||
secondToken, ok := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{
|
||||
Logger: api.Logger,
|
||||
SignedTokenProvider: api.WorkspaceAppsProvider,
|
||||
DashboardURL: api.AccessURL,
|
||||
PathAppBaseURL: api.AccessURL,
|
||||
AppHostname: api.AppHostname,
|
||||
AppRequest: req,
|
||||
})
|
||||
require.True(t, ok)
|
||||
// normalize expiry
|
||||
require.WithinDuration(t, token.Expiry, secondToken.Expiry, 2*time.Second)
|
||||
@@ -304,7 +319,14 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", "/app", nil)
|
||||
r.Header.Set(codersdk.SessionTokenHeader, secondUserClient.SessionToken())
|
||||
|
||||
token, ok := workspaceapps.ResolveRequest(api.Logger, api.AccessURL, api.WorkspaceAppsProvider, rw, r, req)
|
||||
token, ok := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{
|
||||
Logger: api.Logger,
|
||||
SignedTokenProvider: api.WorkspaceAppsProvider,
|
||||
DashboardURL: api.AccessURL,
|
||||
PathAppBaseURL: api.AccessURL,
|
||||
AppHostname: api.AppHostname,
|
||||
AppRequest: req,
|
||||
})
|
||||
w := rw.Result()
|
||||
_ = w.Body.Close()
|
||||
if app == appNameOwner {
|
||||
@@ -336,7 +358,14 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
t.Log("app", app)
|
||||
rw := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/app", nil)
|
||||
token, ok := workspaceapps.ResolveRequest(api.Logger, api.AccessURL, api.WorkspaceAppsProvider, rw, r, req)
|
||||
token, ok := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{
|
||||
Logger: api.Logger,
|
||||
SignedTokenProvider: api.WorkspaceAppsProvider,
|
||||
DashboardURL: api.AccessURL,
|
||||
PathAppBaseURL: api.AccessURL,
|
||||
AppHostname: api.AppHostname,
|
||||
AppRequest: req,
|
||||
})
|
||||
w := rw.Result()
|
||||
if app != appNamePublic {
|
||||
require.False(t, ok)
|
||||
@@ -367,7 +396,14 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
}
|
||||
rw := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/app", nil)
|
||||
token, ok := workspaceapps.ResolveRequest(api.Logger, api.AccessURL, api.WorkspaceAppsProvider, rw, r, req)
|
||||
token, ok := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{
|
||||
Logger: api.Logger,
|
||||
SignedTokenProvider: api.WorkspaceAppsProvider,
|
||||
DashboardURL: api.AccessURL,
|
||||
PathAppBaseURL: api.AccessURL,
|
||||
AppHostname: api.AppHostname,
|
||||
AppRequest: req,
|
||||
})
|
||||
require.False(t, ok)
|
||||
require.Nil(t, token)
|
||||
})
|
||||
@@ -441,7 +477,14 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", "/app", nil)
|
||||
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
|
||||
|
||||
token, ok := workspaceapps.ResolveRequest(api.Logger, api.AccessURL, api.WorkspaceAppsProvider, rw, r, req)
|
||||
token, ok := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{
|
||||
Logger: api.Logger,
|
||||
SignedTokenProvider: api.WorkspaceAppsProvider,
|
||||
DashboardURL: api.AccessURL,
|
||||
PathAppBaseURL: api.AccessURL,
|
||||
AppHostname: api.AppHostname,
|
||||
AppRequest: req,
|
||||
})
|
||||
w := rw.Result()
|
||||
if !assert.Equal(t, c.ok, ok) {
|
||||
dump, err := httputil.DumpResponse(w, true)
|
||||
@@ -505,7 +548,14 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
|
||||
// Even though the token is invalid, we should still perform request
|
||||
// resolution without failure since we'll just ignore the bad token.
|
||||
token, ok := workspaceapps.ResolveRequest(api.Logger, api.AccessURL, api.WorkspaceAppsProvider, rw, r, req)
|
||||
token, ok := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{
|
||||
Logger: api.Logger,
|
||||
SignedTokenProvider: api.WorkspaceAppsProvider,
|
||||
DashboardURL: api.AccessURL,
|
||||
PathAppBaseURL: api.AccessURL,
|
||||
AppHostname: api.AppHostname,
|
||||
AppRequest: req,
|
||||
})
|
||||
require.True(t, ok)
|
||||
require.NotNil(t, token)
|
||||
require.Equal(t, appNameOwner, token.AppSlugOrPort)
|
||||
@@ -539,7 +589,14 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", "/app", nil)
|
||||
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
|
||||
|
||||
token, ok := workspaceapps.ResolveRequest(api.Logger, api.AccessURL, api.WorkspaceAppsProvider, rw, r, req)
|
||||
token, ok := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{
|
||||
Logger: api.Logger,
|
||||
SignedTokenProvider: api.WorkspaceAppsProvider,
|
||||
DashboardURL: api.AccessURL,
|
||||
PathAppBaseURL: api.AccessURL,
|
||||
AppHostname: api.AppHostname,
|
||||
AppRequest: req,
|
||||
})
|
||||
require.False(t, ok)
|
||||
require.Nil(t, token)
|
||||
})
|
||||
@@ -560,7 +617,14 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", "/", nil)
|
||||
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
|
||||
|
||||
token, ok := workspaceapps.ResolveRequest(api.Logger, api.AccessURL, api.WorkspaceAppsProvider, rw, r, req)
|
||||
token, ok := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{
|
||||
Logger: api.Logger,
|
||||
SignedTokenProvider: api.WorkspaceAppsProvider,
|
||||
DashboardURL: api.AccessURL,
|
||||
PathAppBaseURL: api.AccessURL,
|
||||
AppHostname: api.AppHostname,
|
||||
AppRequest: req,
|
||||
})
|
||||
require.True(t, ok)
|
||||
require.Equal(t, req.AppSlugOrPort, token.AppSlugOrPort)
|
||||
require.Equal(t, "http://127.0.0.1:9090", token.AppURL)
|
||||
@@ -579,7 +643,14 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", "/app", nil)
|
||||
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
|
||||
|
||||
token, ok := workspaceapps.ResolveRequest(api.Logger, api.AccessURL, api.WorkspaceAppsProvider, rw, r, req)
|
||||
token, ok := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{
|
||||
Logger: api.Logger,
|
||||
SignedTokenProvider: api.WorkspaceAppsProvider,
|
||||
DashboardURL: api.AccessURL,
|
||||
PathAppBaseURL: api.AccessURL,
|
||||
AppHostname: api.AppHostname,
|
||||
AppRequest: req,
|
||||
})
|
||||
require.True(t, ok)
|
||||
require.Equal(t, req.AccessMethod, token.AccessMethod)
|
||||
require.Equal(t, req.BasePath, token.BasePath)
|
||||
@@ -606,7 +677,14 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", "/app", nil)
|
||||
r.Header.Set(codersdk.SessionTokenHeader, secondUserClient.SessionToken())
|
||||
|
||||
token, ok := workspaceapps.ResolveRequest(api.Logger, api.AccessURL, api.WorkspaceAppsProvider, rw, r, req)
|
||||
token, ok := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{
|
||||
Logger: api.Logger,
|
||||
SignedTokenProvider: api.WorkspaceAppsProvider,
|
||||
DashboardURL: api.AccessURL,
|
||||
PathAppBaseURL: api.AccessURL,
|
||||
AppHostname: api.AppHostname,
|
||||
AppRequest: req,
|
||||
})
|
||||
require.False(t, ok)
|
||||
require.Nil(t, token)
|
||||
})
|
||||
@@ -626,7 +704,14 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", "/app", nil)
|
||||
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
|
||||
|
||||
token, ok := workspaceapps.ResolveRequest(api.Logger, api.AccessURL, api.WorkspaceAppsProvider, rw, r, req)
|
||||
token, ok := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{
|
||||
Logger: api.Logger,
|
||||
SignedTokenProvider: api.WorkspaceAppsProvider,
|
||||
DashboardURL: api.AccessURL,
|
||||
PathAppBaseURL: api.AccessURL,
|
||||
AppHostname: api.AppHostname,
|
||||
AppRequest: req,
|
||||
})
|
||||
require.False(t, ok)
|
||||
require.Nil(t, token)
|
||||
})
|
||||
@@ -645,15 +730,24 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
|
||||
rw := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/some-path", nil)
|
||||
// Should not be used as the hostname in the redirect URI.
|
||||
r.Host = "app.com"
|
||||
|
||||
token, ok := workspaceapps.ResolveRequest(api.Logger, api.AccessURL, api.WorkspaceAppsProvider, rw, r, req)
|
||||
token, ok := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{
|
||||
Logger: api.Logger,
|
||||
SignedTokenProvider: api.WorkspaceAppsProvider,
|
||||
DashboardURL: api.AccessURL,
|
||||
PathAppBaseURL: api.AccessURL,
|
||||
AppHostname: api.AppHostname,
|
||||
AppRequest: req,
|
||||
AppPath: "/some-path",
|
||||
})
|
||||
require.False(t, ok)
|
||||
require.Nil(t, token)
|
||||
|
||||
w := rw.Result()
|
||||
defer w.Body.Close()
|
||||
require.Equal(t, http.StatusTemporaryRedirect, w.StatusCode)
|
||||
require.Equal(t, http.StatusSeeOther, w.StatusCode)
|
||||
|
||||
loc, err := w.Location()
|
||||
require.NoError(t, err)
|
||||
@@ -666,8 +760,11 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
redirectURI, err := url.Parse(redirectURIStr)
|
||||
require.NoError(t, err)
|
||||
|
||||
appHost := fmt.Sprintf("%s--%s--%s--%s", req.AppSlugOrPort, req.AgentNameOrID, req.WorkspaceNameOrID, req.UsernameOrID)
|
||||
host := strings.Replace(api.AppHostname, "*", appHost, 1)
|
||||
|
||||
require.Equal(t, "http", redirectURI.Scheme)
|
||||
require.Equal(t, "app.com", redirectURI.Host)
|
||||
require.Equal(t, host, redirectURI.Host)
|
||||
require.Equal(t, "/some-path", redirectURI.Path)
|
||||
})
|
||||
|
||||
@@ -687,7 +784,14 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", "/app", nil)
|
||||
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
|
||||
|
||||
token, ok := workspaceapps.ResolveRequest(api.Logger, api.AccessURL, api.WorkspaceAppsProvider, rw, r, req)
|
||||
token, ok := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{
|
||||
Logger: api.Logger,
|
||||
SignedTokenProvider: api.WorkspaceAppsProvider,
|
||||
DashboardURL: api.AccessURL,
|
||||
PathAppBaseURL: api.AccessURL,
|
||||
AppHostname: api.AppHostname,
|
||||
AppRequest: req,
|
||||
})
|
||||
require.False(t, ok, "request succeeded even though agent is not connected")
|
||||
require.Nil(t, token)
|
||||
|
||||
@@ -741,7 +845,14 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", "/app", nil)
|
||||
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
|
||||
|
||||
token, ok := workspaceapps.ResolveRequest(api.Logger, api.AccessURL, api.WorkspaceAppsProvider, rw, r, req)
|
||||
token, ok := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{
|
||||
Logger: api.Logger,
|
||||
SignedTokenProvider: api.WorkspaceAppsProvider,
|
||||
DashboardURL: api.AccessURL,
|
||||
PathAppBaseURL: api.AccessURL,
|
||||
AppHostname: api.AppHostname,
|
||||
AppRequest: req,
|
||||
})
|
||||
require.False(t, ok, "request succeeded even though app is unhealthy")
|
||||
require.Nil(t, token)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/coderd/httpmw"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
@@ -19,24 +20,50 @@ const (
|
||||
RedirectURIQueryParam = "redirect_uri"
|
||||
)
|
||||
|
||||
// ResolveRequest calls SignedTokenProvider to use an existing signed app token in the
|
||||
// request or issue a new one. If it returns a newly minted token, it sets the
|
||||
// cookie for you.
|
||||
func ResolveRequest(log slog.Logger, dashboardURL *url.URL, p SignedTokenProvider, rw http.ResponseWriter, r *http.Request, appReq Request) (*SignedToken, bool) {
|
||||
appReq = appReq.Normalize()
|
||||
type ResolveRequestOptions struct {
|
||||
Logger slog.Logger
|
||||
SignedTokenProvider SignedTokenProvider
|
||||
|
||||
DashboardURL *url.URL
|
||||
PathAppBaseURL *url.URL
|
||||
AppHostname string
|
||||
|
||||
AppRequest Request
|
||||
// TODO: Replace these 2 fields with a "BrowserURL" field which is used for
|
||||
// redirecting the user back to their initial request after authenticating.
|
||||
// AppPath is the path under the app that was hit.
|
||||
AppPath string
|
||||
// AppQuery is the raw query of the request.
|
||||
AppQuery string
|
||||
}
|
||||
|
||||
func ResolveRequest(rw http.ResponseWriter, r *http.Request, opts ResolveRequestOptions) (*SignedToken, bool) {
|
||||
appReq := opts.AppRequest.Normalize()
|
||||
err := appReq.Validate()
|
||||
if err != nil {
|
||||
WriteWorkspaceApp500(log, dashboardURL, rw, r, &appReq, err, "invalid app request")
|
||||
// This is a 500 since it's a coder server or proxy that's making this
|
||||
// request struct based on details from the request. The values should
|
||||
// already be validated before they are put into the struct.
|
||||
WriteWorkspaceApp500(opts.Logger, opts.DashboardURL, rw, r, &appReq, err, "invalid app request")
|
||||
return nil, false
|
||||
}
|
||||
|
||||
token, ok := p.TokenFromRequest(r)
|
||||
token, ok := opts.SignedTokenProvider.FromRequest(r)
|
||||
if ok && token.MatchesRequest(appReq) {
|
||||
// The request has a valid signed app token and it matches the request.
|
||||
return token, true
|
||||
}
|
||||
|
||||
token, tokenStr, ok := p.CreateToken(r.Context(), rw, r, appReq)
|
||||
issueReq := IssueTokenRequest{
|
||||
AppRequest: appReq,
|
||||
PathAppBaseURL: opts.PathAppBaseURL.String(),
|
||||
AppHostname: opts.AppHostname,
|
||||
SessionToken: httpmw.APITokenFromRequest(r),
|
||||
AppPath: opts.AppPath,
|
||||
AppQuery: opts.AppQuery,
|
||||
}
|
||||
|
||||
token, tokenStr, ok := opts.SignedTokenProvider.Issue(r.Context(), rw, r, issueReq)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
@@ -56,17 +83,17 @@ func ResolveRequest(log slog.Logger, dashboardURL *url.URL, p SignedTokenProvide
|
||||
|
||||
// SignedTokenProvider provides signed workspace app tokens (aka. app tickets).
|
||||
type SignedTokenProvider interface {
|
||||
// TokenFromRequest returns a parsed token from the request. If the request
|
||||
// does not contain a signed app token or is is invalid (expired, invalid
|
||||
// FromRequest returns a parsed token from the request. If the request does
|
||||
// not contain a signed app token or is is invalid (expired, invalid
|
||||
// signature, etc.), it returns false.
|
||||
TokenFromRequest(r *http.Request) (*SignedToken, bool)
|
||||
// CreateToken mints a new token for the given app request. It uses the
|
||||
// long-lived session token in the HTTP request to authenticate and
|
||||
// authorize the client for the given workspace app. The token is returned
|
||||
// in struct and string form. The string form should be written as a cookie.
|
||||
FromRequest(r *http.Request) (*SignedToken, bool)
|
||||
// Issue mints a new token for the given app request. It uses the long-lived
|
||||
// session token in the HTTP request to authenticate and authorize the
|
||||
// client for the given workspace app. The token is returned in struct and
|
||||
// string form. The string form should be written as a cookie.
|
||||
//
|
||||
// If the request is invalid or the user is not authorized to access the
|
||||
// app, false is returned. An error page is written to the response writer
|
||||
// in this case.
|
||||
CreateToken(ctx context.Context, rw http.ResponseWriter, r *http.Request, appReq Request) (*SignedToken, string, bool)
|
||||
Issue(ctx context.Context, rw http.ResponseWriter, r *http.Request, appReq IssueTokenRequest) (*SignedToken, string, bool)
|
||||
}
|
||||
|
||||
+167
-98
@@ -78,14 +78,22 @@ type Server struct {
|
||||
Hostname string
|
||||
// HostnameRegex contains the regex version of Hostname as generated by
|
||||
// httpapi.CompileHostnamePattern(). It MUST be set if Hostname is set.
|
||||
HostnameRegex *regexp.Regexp
|
||||
DeploymentValues *codersdk.DeploymentValues
|
||||
RealIPConfig *httpmw.RealIPConfig
|
||||
HostnameRegex *regexp.Regexp
|
||||
RealIPConfig *httpmw.RealIPConfig
|
||||
|
||||
SignedTokenProvider SignedTokenProvider
|
||||
WorkspaceConnCache *wsconncache.Cache
|
||||
AppSecurityKey SecurityKey
|
||||
|
||||
// DisablePathApps disables path-based apps. This is a security feature as path
|
||||
// based apps share the same cookie as the dashboard, and are susceptible to XSS
|
||||
// by a malicious workspace app.
|
||||
//
|
||||
// Subdomain apps are safer with their cookies scoped to the subdomain, and XSS
|
||||
// calls to the dashboard are not possible due to CORs.
|
||||
DisablePathApps bool
|
||||
SecureAuthCookie bool
|
||||
|
||||
websocketWaitMutex sync.Mutex
|
||||
websocketWaitGroup sync.WaitGroup
|
||||
}
|
||||
@@ -117,10 +125,109 @@ func (s *Server) Attach(r chi.Router) {
|
||||
r.Get("/api/v2/workspaceagents/{workspaceagent}/pty", s.workspaceAgentPTY)
|
||||
}
|
||||
|
||||
// handleAPIKeySmuggling is called by the proxy path and subdomain handlers to
|
||||
// process any "smuggled" API keys in the query parameters.
|
||||
//
|
||||
// If a smuggled key is found, it is decrypted and the cookie is set, and the
|
||||
// user is redirected to strip the query parameter.
|
||||
func (s *Server) handleAPIKeySmuggling(rw http.ResponseWriter, r *http.Request, accessMethod AccessMethod) bool {
|
||||
ctx := r.Context()
|
||||
|
||||
encryptedAPIKey := r.URL.Query().Get(SubdomainProxyAPIKeyParam)
|
||||
if encryptedAPIKey == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
// API key smuggling is not permitted for path apps on the primary access
|
||||
// URL. The user is already covered by their full session token.
|
||||
if accessMethod == AccessMethodPath && s.AccessURL.Host == s.DashboardURL.Host {
|
||||
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
|
||||
Status: http.StatusBadRequest,
|
||||
Title: "Bad Request",
|
||||
Description: "Could not decrypt API key. Workspace app API key smuggling is not permitted on the primary access URL. Please remove the query parameter and try again.",
|
||||
// Retry is disabled because the user needs to remove the query
|
||||
// parameter before they try again.
|
||||
RetryEnabled: false,
|
||||
DashboardURL: s.DashboardURL.String(),
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
// Exchange the encoded API key for a real one.
|
||||
token, err := s.AppSecurityKey.DecryptAPIKey(encryptedAPIKey)
|
||||
if err != nil {
|
||||
s.Logger.Debug(ctx, "could not decrypt smuggled workspace app API key", slog.Error(err))
|
||||
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
|
||||
Status: http.StatusBadRequest,
|
||||
Title: "Bad Request",
|
||||
Description: "Could not decrypt API key. Please remove the query parameter and try again.",
|
||||
// Retry is disabled because the user needs to remove the query
|
||||
// parameter before they try again.
|
||||
RetryEnabled: false,
|
||||
DashboardURL: s.DashboardURL.String(),
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
// Set the cookie. For subdomain apps, we set the cookie on the whole
|
||||
// wildcard so users don't need to re-auth for every subdomain app they
|
||||
// access. For path apps (only on proxies, see above) we just set it on the
|
||||
// current domain.
|
||||
domain := "" // use the current domain
|
||||
if accessMethod == AccessMethodSubdomain {
|
||||
hostSplit := strings.SplitN(s.Hostname, ".", 2)
|
||||
if len(hostSplit) != 2 {
|
||||
// This should be impossible as we verify the app hostname on
|
||||
// startup, but we'll check anyways.
|
||||
s.Logger.Error(r.Context(), "could not split invalid app hostname", slog.F("hostname", s.Hostname))
|
||||
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
|
||||
Status: http.StatusInternalServerError,
|
||||
Title: "Internal Server Error",
|
||||
Description: "The app is configured with an invalid app wildcard hostname. Please contact an administrator.",
|
||||
RetryEnabled: false,
|
||||
DashboardURL: s.DashboardURL.String(),
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
// Set the cookie for all subdomains of s.Hostname.
|
||||
domain = "." + hostSplit[1]
|
||||
}
|
||||
|
||||
// We don't set an expiration because the key in the database already has an
|
||||
// expiration, and expired tokens don't affect the user experience (they get
|
||||
// auto-redirected to re-smuggle the API key).
|
||||
http.SetCookie(rw, &http.Cookie{
|
||||
Name: codersdk.DevURLSessionTokenCookie,
|
||||
Value: token,
|
||||
Domain: domain,
|
||||
Path: "/",
|
||||
MaxAge: 0,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: s.SecureAuthCookie,
|
||||
})
|
||||
|
||||
// Strip the query parameter.
|
||||
path := r.URL.Path
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
q := r.URL.Query()
|
||||
q.Del(SubdomainProxyAPIKeyParam)
|
||||
rawQuery := q.Encode()
|
||||
if rawQuery != "" {
|
||||
path += "?" + q.Encode()
|
||||
}
|
||||
|
||||
http.Redirect(rw, r, path, http.StatusSeeOther)
|
||||
return false
|
||||
}
|
||||
|
||||
// workspaceAppsProxyPath proxies requests to a workspace application
|
||||
// through a relative URL path.
|
||||
func (s *Server) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request) {
|
||||
if s.DeploymentValues.DisablePathApps.Value() {
|
||||
if s.DisablePathApps {
|
||||
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
|
||||
Status: http.StatusUnauthorized,
|
||||
Title: "Unauthorized",
|
||||
@@ -144,6 +251,10 @@ func (s *Server) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
if !s.handleAPIKeySmuggling(rw, r, AccessMethodPath) {
|
||||
return
|
||||
}
|
||||
|
||||
// Determine the real path that was hit. The * URL parameter in Chi will not
|
||||
// include the leading slash if it was present, so we need to add it back.
|
||||
chiPath := chi.URLParam(r, "*")
|
||||
@@ -154,14 +265,23 @@ func (s *Server) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request)
|
||||
|
||||
// ResolveRequest will only return a new signed token if the actor has the RBAC
|
||||
// permissions to connect to a workspace.
|
||||
token, ok := ResolveRequest(s.Logger, s.DashboardURL, s.SignedTokenProvider, rw, r, Request{
|
||||
AccessMethod: AccessMethodPath,
|
||||
BasePath: basePath,
|
||||
UsernameOrID: chi.URLParam(r, "user"),
|
||||
WorkspaceAndAgent: chi.URLParam(r, "workspace_and_agent"),
|
||||
// We don't support port proxying on paths. The ResolveRequest method
|
||||
// won't allow port proxying on path-based apps if the app is a number.
|
||||
AppSlugOrPort: chi.URLParam(r, "workspaceapp"),
|
||||
token, ok := ResolveRequest(rw, r, ResolveRequestOptions{
|
||||
Logger: s.Logger,
|
||||
SignedTokenProvider: s.SignedTokenProvider,
|
||||
DashboardURL: s.DashboardURL,
|
||||
PathAppBaseURL: s.AccessURL,
|
||||
AppHostname: s.Hostname,
|
||||
AppRequest: Request{
|
||||
AccessMethod: AccessMethodPath,
|
||||
BasePath: basePath,
|
||||
UsernameOrID: chi.URLParam(r, "user"),
|
||||
WorkspaceAndAgent: chi.URLParam(r, "workspace_and_agent"),
|
||||
// We don't support port proxying on paths. The ResolveRequest method
|
||||
// won't allow port proxying on path-based apps if the app is a number.
|
||||
AppSlugOrPort: chi.URLParam(r, "workspaceapp"),
|
||||
},
|
||||
AppPath: chiPath,
|
||||
AppQuery: r.URL.RawQuery,
|
||||
})
|
||||
if !ok {
|
||||
return
|
||||
@@ -170,7 +290,7 @@ func (s *Server) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request)
|
||||
s.proxyWorkspaceApp(rw, r, *token, chiPath)
|
||||
}
|
||||
|
||||
// SubdomainAppMW handles subdomain-based application proxy requests (aka.
|
||||
// HandleSubdomain handles subdomain-based application proxy requests (aka.
|
||||
// DevURLs in Coder V1).
|
||||
//
|
||||
// There are a lot of paths here:
|
||||
@@ -205,7 +325,7 @@ func (s *Server) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request)
|
||||
// 6. We finally verify that the "rest" matches api.Hostname for security
|
||||
// purposes regarding re-authentication and application proxy session
|
||||
// tokens.
|
||||
func (s *Server) SubdomainAppMW(middlewares ...func(http.Handler) http.Handler) func(http.Handler) http.Handler {
|
||||
func (s *Server) HandleSubdomain(middlewares ...func(http.Handler) http.Handler) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
@@ -241,50 +361,26 @@ func (s *Server) SubdomainAppMW(middlewares ...func(http.Handler) http.Handler)
|
||||
return
|
||||
}
|
||||
|
||||
// If the request has the special query param then we need to set a
|
||||
// cookie and strip that query parameter.
|
||||
if encryptedAPIKey := r.URL.Query().Get(SubdomainProxyAPIKeyParam); encryptedAPIKey != "" {
|
||||
// Exchange the encoded API key for a real one.
|
||||
token, err := s.AppSecurityKey.DecryptAPIKey(encryptedAPIKey)
|
||||
if err != nil {
|
||||
s.Logger.Debug(ctx, "could not decrypt API key", slog.Error(err))
|
||||
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
|
||||
Status: http.StatusBadRequest,
|
||||
Title: "Bad Request",
|
||||
Description: "Could not decrypt API key. Please remove the query parameter and try again.",
|
||||
// Retry is disabled because the user needs to remove
|
||||
// the query parameter before they try again.
|
||||
RetryEnabled: false,
|
||||
DashboardURL: s.DashboardURL.String(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
s.setWorkspaceAppCookie(rw, r, token)
|
||||
|
||||
// Strip the query parameter.
|
||||
path := r.URL.Path
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
q := r.URL.Query()
|
||||
q.Del(SubdomainProxyAPIKeyParam)
|
||||
rawQuery := q.Encode()
|
||||
if rawQuery != "" {
|
||||
path += "?" + q.Encode()
|
||||
}
|
||||
|
||||
http.Redirect(rw, r, path, http.StatusTemporaryRedirect)
|
||||
if !s.handleAPIKeySmuggling(rw, r, AccessMethodSubdomain) {
|
||||
return
|
||||
}
|
||||
|
||||
token, ok := ResolveRequest(s.Logger, s.DashboardURL, s.SignedTokenProvider, rw, r, Request{
|
||||
AccessMethod: AccessMethodSubdomain,
|
||||
BasePath: "/",
|
||||
UsernameOrID: app.Username,
|
||||
WorkspaceNameOrID: app.WorkspaceName,
|
||||
AgentNameOrID: app.AgentName,
|
||||
AppSlugOrPort: app.AppSlugOrPort,
|
||||
token, ok := ResolveRequest(rw, r, ResolveRequestOptions{
|
||||
Logger: s.Logger,
|
||||
SignedTokenProvider: s.SignedTokenProvider,
|
||||
DashboardURL: s.DashboardURL,
|
||||
PathAppBaseURL: s.AccessURL,
|
||||
AppHostname: s.Hostname,
|
||||
AppRequest: Request{
|
||||
AccessMethod: AccessMethodSubdomain,
|
||||
BasePath: "/",
|
||||
UsernameOrID: app.Username,
|
||||
WorkspaceNameOrID: app.WorkspaceName,
|
||||
AgentNameOrID: app.AgentName,
|
||||
AppSlugOrPort: app.AppSlugOrPort,
|
||||
},
|
||||
AppPath: r.URL.Path,
|
||||
AppQuery: r.URL.RawQuery,
|
||||
})
|
||||
if !ok {
|
||||
return
|
||||
@@ -333,7 +429,7 @@ func (s *Server) parseHostname(rw http.ResponseWriter, r *http.Request, next htt
|
||||
// Check if the request is part of the deprecated logout flow. If so, we
|
||||
// just redirect to the main access URL.
|
||||
if subdomain == appLogoutHostname {
|
||||
http.Redirect(rw, r, s.AccessURL.String(), http.StatusTemporaryRedirect)
|
||||
http.Redirect(rw, r, s.AccessURL.String(), http.StatusSeeOther)
|
||||
return httpapi.ApplicationURL{}, false
|
||||
}
|
||||
|
||||
@@ -353,44 +449,6 @@ func (s *Server) parseHostname(rw http.ResponseWriter, r *http.Request, next htt
|
||||
return app, true
|
||||
}
|
||||
|
||||
// setWorkspaceAppCookie sets a cookie on the workspace app domain. If the app
|
||||
// hostname cannot be parsed properly, a static error page is rendered and false
|
||||
// is returned.
|
||||
func (s *Server) setWorkspaceAppCookie(rw http.ResponseWriter, r *http.Request, token string) bool {
|
||||
hostSplit := strings.SplitN(s.Hostname, ".", 2)
|
||||
if len(hostSplit) != 2 {
|
||||
// This should be impossible as we verify the app hostname on
|
||||
// startup, but we'll check anyways.
|
||||
s.Logger.Error(r.Context(), "could not split invalid app hostname", slog.F("hostname", s.Hostname))
|
||||
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
|
||||
Status: http.StatusInternalServerError,
|
||||
Title: "Internal Server Error",
|
||||
Description: "The app is configured with an invalid app wildcard hostname. Please contact an administrator.",
|
||||
RetryEnabled: false,
|
||||
DashboardURL: s.DashboardURL.String(),
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
// Set the app cookie for all subdomains of s.Hostname. We don't set an
|
||||
// expiration because the key in the database already has an expiration, and
|
||||
// expired tokens don't affect the user experience (they get auto-redirected
|
||||
// to re-smuggle the API key).
|
||||
cookieHost := "." + hostSplit[1]
|
||||
http.SetCookie(rw, &http.Cookie{
|
||||
Name: codersdk.DevURLSessionTokenCookie,
|
||||
Value: token,
|
||||
Domain: cookieHost,
|
||||
Path: "/",
|
||||
MaxAge: 0,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: s.DeploymentValues.SecureAuthCookie.Value(),
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) proxyWorkspaceApp(rw http.ResponseWriter, r *http.Request, appToken SignedToken, path string) {
|
||||
ctx := r.Context()
|
||||
|
||||
@@ -525,10 +583,19 @@ func (s *Server) workspaceAgentPTY(rw http.ResponseWriter, r *http.Request) {
|
||||
s.websocketWaitMutex.Unlock()
|
||||
defer s.websocketWaitGroup.Done()
|
||||
|
||||
appToken, ok := ResolveRequest(s.Logger, s.AccessURL, s.SignedTokenProvider, rw, r, Request{
|
||||
AccessMethod: AccessMethodTerminal,
|
||||
BasePath: r.URL.Path,
|
||||
AgentNameOrID: chi.URLParam(r, "workspaceagent"),
|
||||
appToken, ok := ResolveRequest(rw, r, ResolveRequestOptions{
|
||||
Logger: s.Logger,
|
||||
SignedTokenProvider: s.SignedTokenProvider,
|
||||
DashboardURL: s.DashboardURL,
|
||||
PathAppBaseURL: s.AccessURL,
|
||||
AppHostname: s.Hostname,
|
||||
AppRequest: Request{
|
||||
AccessMethod: AccessMethodTerminal,
|
||||
BasePath: r.URL.Path,
|
||||
AgentNameOrID: chi.URLParam(r, "workspaceagent"),
|
||||
},
|
||||
AppPath: r.URL.Path,
|
||||
AppQuery: "",
|
||||
})
|
||||
if !ok {
|
||||
return
|
||||
@@ -565,12 +632,14 @@ func (s *Server) workspaceAgentPTY(rw http.ResponseWriter, r *http.Request) {
|
||||
|
||||
agentConn, release, err := s.WorkspaceConnCache.Acquire(appToken.AgentID)
|
||||
if err != nil {
|
||||
s.Logger.Debug(ctx, "dial workspace agent", slog.Error(err))
|
||||
_ = conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("dial workspace agent: %s", err))
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
ptNetConn, err := agentConn.ReconnectingPTY(ctx, reconnect, uint16(height), uint16(width), r.URL.Query().Get("command"))
|
||||
if err != nil {
|
||||
s.Logger.Debug(ctx, "dial reconnecting pty server in workspace agent", slog.Error(err))
|
||||
_ = conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("dial: %s", err))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -25,6 +26,50 @@ const (
|
||||
AccessMethodTerminal AccessMethod = "terminal"
|
||||
)
|
||||
|
||||
type IssueTokenRequest struct {
|
||||
AppRequest Request `json:"app_request"`
|
||||
// PathAppBaseURL is required.
|
||||
PathAppBaseURL string `json:"path_app_base_url"`
|
||||
// AppHostname is the optional hostname for subdomain apps on the external
|
||||
// proxy. It must start with an asterisk.
|
||||
AppHostname string `json:"app_hostname"`
|
||||
// AppPath is the path of the user underneath the app base path.
|
||||
AppPath string `json:"app_path"`
|
||||
// AppQuery is the query parameters the user provided in the app request.
|
||||
AppQuery string `json:"app_query"`
|
||||
// SessionToken is the session token provided by the user.
|
||||
SessionToken string `json:"session_token"`
|
||||
}
|
||||
|
||||
// AppBaseURL returns the base URL of this specific app request. An error is
|
||||
// returned if a subdomain app hostname is not provided but the app is a
|
||||
// subdomain app.
|
||||
func (r IssueTokenRequest) AppBaseURL() (*url.URL, error) {
|
||||
u, err := url.Parse(r.PathAppBaseURL)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse path app base URL: %w", err)
|
||||
}
|
||||
|
||||
switch r.AppRequest.AccessMethod {
|
||||
case AccessMethodPath, AccessMethodTerminal:
|
||||
u.Path = r.AppRequest.BasePath
|
||||
if !strings.HasSuffix(u.Path, "/") {
|
||||
u.Path += "/"
|
||||
}
|
||||
return u, nil
|
||||
case AccessMethodSubdomain:
|
||||
if r.AppHostname == "" {
|
||||
return nil, xerrors.New("subdomain app hostname is required to generate subdomain app URL")
|
||||
}
|
||||
appHost := fmt.Sprintf("%s--%s--%s--%s", r.AppRequest.AppSlugOrPort, r.AppRequest.AgentNameOrID, r.AppRequest.WorkspaceNameOrID, r.AppRequest.UsernameOrID)
|
||||
u.Host = strings.Replace(r.AppHostname, "*", appHost, 1)
|
||||
u.Path = r.AppRequest.BasePath
|
||||
return u, nil
|
||||
default:
|
||||
return nil, xerrors.Errorf("invalid access method: %q", r.AppRequest.AccessMethod)
|
||||
}
|
||||
}
|
||||
|
||||
type Request struct {
|
||||
AccessMethod AccessMethod `json:"access_method"`
|
||||
// BasePath of the app. For path apps, this is the path prefix in the router
|
||||
@@ -128,7 +173,7 @@ type databaseRequest struct {
|
||||
|
||||
// AppURL is the resolved URL to the workspace app. This is only set for non
|
||||
// terminal requests.
|
||||
AppURL string
|
||||
AppURL *url.URL
|
||||
// AppHealth is the health of the app. For terminal requests, this is always
|
||||
// database.WorkspaceAppHealthHealthy.
|
||||
AppHealth database.WorkspaceAppHealth
|
||||
@@ -290,12 +335,17 @@ func (r Request) getDatabase(ctx context.Context, db database.Store) (*databaseR
|
||||
}
|
||||
}
|
||||
|
||||
appURLParsed, err := url.Parse(appURL)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse app URL %q: %w", appURL, err)
|
||||
}
|
||||
|
||||
return &databaseRequest{
|
||||
Request: r,
|
||||
User: user,
|
||||
Workspace: workspace,
|
||||
Agent: agent,
|
||||
AppURL: appURL,
|
||||
AppURL: appURLParsed,
|
||||
AppHealth: appHealth,
|
||||
AppSharingLevel: appSharingLevel,
|
||||
}, nil
|
||||
@@ -348,7 +398,7 @@ func (r Request) getDatabaseTerminal(ctx context.Context, db database.Store) (*d
|
||||
User: user,
|
||||
Workspace: workspace,
|
||||
Agent: agent,
|
||||
AppURL: "",
|
||||
AppURL: nil,
|
||||
AppHealth: database.WorkspaceAppHealthHealthy,
|
||||
AppSharingLevel: database.AppSharingLevelOwner,
|
||||
}, nil
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-jose/go-jose/v3"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -217,3 +219,23 @@ func (k SecurityKey) DecryptAPIKey(encryptedAPIKey string) (string, error) {
|
||||
|
||||
return payload.APIKey, nil
|
||||
}
|
||||
|
||||
func FromRequest(r *http.Request, key SecurityKey) (*SignedToken, bool) {
|
||||
// Get the existing token from the request.
|
||||
tokenCookie, err := r.Cookie(codersdk.DevURLSignedAppTokenCookie)
|
||||
if err == nil {
|
||||
token, err := key.VerifySignedToken(tokenCookie.Value)
|
||||
if err == nil {
|
||||
req := token.Request.Normalize()
|
||||
err := req.Validate()
|
||||
if err == nil {
|
||||
// The request has a valid signed app token, which is a valid
|
||||
// token signed by us. The caller must check that it matches
|
||||
// the request.
|
||||
return &token, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package coderd_test
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
@@ -10,8 +11,13 @@ import (
|
||||
|
||||
"github.com/coder/coder/cli/clibase"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/database/dbgen"
|
||||
"github.com/coder/coder/coderd/database/dbtestutil"
|
||||
"github.com/coder/coder/coderd/httpmw"
|
||||
"github.com/coder/coder/coderd/workspaceapps"
|
||||
"github.com/coder/coder/coderd/workspaceapps/apptest"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
|
||||
@@ -78,6 +84,171 @@ func TestGetAppHost(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceApplicationAuth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
accessURL string
|
||||
appHostname string
|
||||
proxyURL string
|
||||
proxyAppHostname string
|
||||
|
||||
redirectURI string
|
||||
expectRedirect string
|
||||
}{
|
||||
{
|
||||
name: "OK",
|
||||
accessURL: "https://test.coder.com",
|
||||
appHostname: "*.test.coder.com",
|
||||
proxyURL: "https://proxy.test.coder.com",
|
||||
proxyAppHostname: "*.proxy.test.coder.com",
|
||||
redirectURI: "https://something.test.coder.com",
|
||||
expectRedirect: "https://something.test.coder.com",
|
||||
},
|
||||
{
|
||||
name: "ProxyPathOK",
|
||||
accessURL: "https://test.coder.com",
|
||||
appHostname: "*.test.coder.com",
|
||||
proxyURL: "https://proxy.test.coder.com",
|
||||
proxyAppHostname: "*.proxy.test.coder.com",
|
||||
redirectURI: "https://proxy.test.coder.com/path",
|
||||
expectRedirect: "https://proxy.test.coder.com/path",
|
||||
},
|
||||
{
|
||||
name: "ProxySubdomainOK",
|
||||
accessURL: "https://test.coder.com",
|
||||
appHostname: "*.test.coder.com",
|
||||
proxyURL: "https://proxy.test.coder.com",
|
||||
proxyAppHostname: "*.proxy.test.coder.com",
|
||||
redirectURI: "https://something.proxy.test.coder.com/path?yeah=true",
|
||||
expectRedirect: "https://something.proxy.test.coder.com/path?yeah=true",
|
||||
},
|
||||
{
|
||||
name: "ProxySubdomainSuffixOK",
|
||||
accessURL: "https://test.coder.com",
|
||||
appHostname: "*.test.coder.com",
|
||||
proxyURL: "https://proxy.test.coder.com",
|
||||
proxyAppHostname: "*--suffix.proxy.test.coder.com",
|
||||
redirectURI: "https://something--suffix.proxy.test.coder.com/",
|
||||
expectRedirect: "https://something--suffix.proxy.test.coder.com/",
|
||||
},
|
||||
{
|
||||
name: "NormalizeSchemePrimaryAppHostname",
|
||||
accessURL: "https://test.coder.com",
|
||||
appHostname: "*.test.coder.com",
|
||||
proxyURL: "https://proxy.test.coder.com",
|
||||
proxyAppHostname: "*.proxy.test.coder.com",
|
||||
redirectURI: "http://x.test.coder.com",
|
||||
expectRedirect: "https://x.test.coder.com",
|
||||
},
|
||||
{
|
||||
name: "NormalizeSchemeProxyAppHostname",
|
||||
accessURL: "https://test.coder.com",
|
||||
appHostname: "*.test.coder.com",
|
||||
proxyURL: "https://proxy.test.coder.com",
|
||||
proxyAppHostname: "*.proxy.test.coder.com",
|
||||
redirectURI: "http://x.proxy.test.coder.com",
|
||||
expectRedirect: "https://x.proxy.test.coder.com",
|
||||
},
|
||||
{
|
||||
name: "NoneError",
|
||||
accessURL: "https://test.coder.com",
|
||||
appHostname: "*.test.coder.com",
|
||||
proxyURL: "https://proxy.test.coder.com",
|
||||
proxyAppHostname: "*.proxy.test.coder.com",
|
||||
redirectURI: "",
|
||||
expectRedirect: "",
|
||||
},
|
||||
{
|
||||
name: "PrimaryAccessURLError",
|
||||
accessURL: "https://test.coder.com",
|
||||
appHostname: "*.test.coder.com",
|
||||
proxyURL: "https://proxy.test.coder.com",
|
||||
proxyAppHostname: "*.proxy.test.coder.com",
|
||||
redirectURI: "https://test.coder.com/",
|
||||
expectRedirect: "",
|
||||
},
|
||||
{
|
||||
name: "OtherError",
|
||||
accessURL: "https://test.coder.com",
|
||||
appHostname: "*.test.coder.com",
|
||||
proxyURL: "https://proxy.test.coder.com",
|
||||
proxyAppHostname: "*.proxy.test.coder.com",
|
||||
redirectURI: "https://example.com/",
|
||||
expectRedirect: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
c := c
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, pubsub := dbtestutil.NewDB(t)
|
||||
|
||||
accessURL, err := url.Parse(c.accessURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
Database: db,
|
||||
Pubsub: pubsub,
|
||||
AccessURL: accessURL,
|
||||
AppHostname: c.appHostname,
|
||||
})
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
// Disable redirects.
|
||||
client.HTTPClient.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
|
||||
_, _ = dbgen.WorkspaceProxy(t, db, database.WorkspaceProxy{
|
||||
Url: c.proxyURL,
|
||||
WildcardHostname: c.proxyAppHostname,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
resp, err := client.Request(ctx, http.MethodGet, "/api/v2/applications/auth-redirect", nil, func(req *http.Request) {
|
||||
q := req.URL.Query()
|
||||
q.Set("redirect_uri", c.redirectURI)
|
||||
req.URL.RawQuery = q.Encode()
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusSeeOther {
|
||||
err = codersdk.ReadBodyAsError(resp)
|
||||
if c.expectRedirect == "" {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
if c.expectRedirect == "" {
|
||||
t.Fatal("expected a failure but got a success")
|
||||
}
|
||||
|
||||
loc, err := resp.Location()
|
||||
require.NoError(t, err)
|
||||
q := loc.Query()
|
||||
|
||||
// Verify the API key is set.
|
||||
encryptedAPIKey := loc.Query().Get(workspaceapps.SubdomainProxyAPIKeyParam)
|
||||
require.NotEmpty(t, encryptedAPIKey, "no API key was set in the query parameters")
|
||||
|
||||
// Strip the API key from the actual redirect URI and compare.
|
||||
q.Del(workspaceapps.SubdomainProxyAPIKeyParam)
|
||||
loc.RawQuery = q.Encode()
|
||||
require.Equal(t, c.expectRedirect, loc.String())
|
||||
|
||||
// The decrypted key is verified in the apptest test suite.
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceApps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -87,6 +258,10 @@ func TestWorkspaceApps(t *testing.T) {
|
||||
deploymentValues.Dangerous.AllowPathAppSharing = clibase.Bool(opts.DangerousAllowPathAppSharing)
|
||||
deploymentValues.Dangerous.AllowPathAppSiteOwnerAccess = clibase.Bool(opts.DangerousAllowPathAppSiteOwnerAccess)
|
||||
|
||||
if opts.DisableSubdomainApps {
|
||||
opts.AppHost = ""
|
||||
}
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
DeploymentValues: deploymentValues,
|
||||
AppHostname: opts.AppHost,
|
||||
@@ -105,10 +280,11 @@ func TestWorkspaceApps(t *testing.T) {
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
return &apptest.Deployment{
|
||||
Options: opts,
|
||||
Client: client,
|
||||
FirstUser: user,
|
||||
PathAppBaseURL: client.URL,
|
||||
Options: opts,
|
||||
SDKClient: client,
|
||||
FirstUser: user,
|
||||
PathAppBaseURL: client.URL,
|
||||
AppHostIsPrimary: true,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+10
-1
@@ -79,6 +79,10 @@ type Client struct {
|
||||
HTTPClient *http.Client
|
||||
URL *url.URL
|
||||
|
||||
// SessionTokenHeader is an optional custom header to use for setting tokens. By
|
||||
// default 'Coder-Session-Token' is used.
|
||||
SessionTokenHeader string
|
||||
|
||||
// Logger is optionally provided to log requests.
|
||||
// Method, URL, and response code will be logged by default.
|
||||
Logger slog.Logger
|
||||
@@ -150,7 +154,12 @@ func (c *Client) Request(ctx context.Context, method, path string, body interfac
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set(SessionTokenHeader, c.SessionToken())
|
||||
|
||||
tokenHeader := c.SessionTokenHeader
|
||||
if tokenHeader == "" {
|
||||
tokenHeader = SessionTokenHeader
|
||||
}
|
||||
req.Header.Set(tokenHeader, c.SessionToken())
|
||||
|
||||
if r != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@@ -1575,6 +1575,20 @@ type BuildInfoResponse struct {
|
||||
ExternalURL string `json:"external_url"`
|
||||
// Version returns the semantic version of the build.
|
||||
Version string `json:"version"`
|
||||
|
||||
// DashboardURL is the URL to hit the deployment's dashboard.
|
||||
// For external workspace proxies, this is the coderd they are connected
|
||||
// to.
|
||||
DashboardURL string `json:"dashboard_url"`
|
||||
|
||||
WorkspaceProxy bool `json:"workspace_proxy"`
|
||||
}
|
||||
|
||||
type WorkspaceProxyBuildInfo struct {
|
||||
// TODO: @emyrk what should we include here?
|
||||
WorkspaceProxy bool `json:"workspace_proxy"`
|
||||
// DashboardURL is the URL of the coderd this proxy is connected to.
|
||||
DashboardURL string `json:"dashboard_url"`
|
||||
}
|
||||
|
||||
// CanonicalVersion trims build information from the version.
|
||||
|
||||
@@ -200,18 +200,12 @@ func (c *Client) DialWorkspaceAgent(ctx context.Context, agentID uuid.UUID, opti
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse url: %w", err)
|
||||
}
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("create cookie jar: %w", err)
|
||||
}
|
||||
jar.SetCookies(coordinateURL, []*http.Cookie{{
|
||||
Name: SessionTokenCookie,
|
||||
Value: c.SessionToken(),
|
||||
}})
|
||||
httpClient := &http.Client{
|
||||
Jar: jar,
|
||||
Transport: c.HTTPClient.Transport,
|
||||
coordinateHeaders := make(http.Header)
|
||||
tokenHeader := SessionTokenHeader
|
||||
if c.SessionTokenHeader != "" {
|
||||
tokenHeader = c.SessionTokenHeader
|
||||
}
|
||||
coordinateHeaders.Set(tokenHeader, c.SessionToken())
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer func() {
|
||||
if err != nil {
|
||||
@@ -227,7 +221,8 @@ func (c *Client) DialWorkspaceAgent(ctx context.Context, agentID uuid.UUID, opti
|
||||
options.Logger.Debug(ctx, "connecting")
|
||||
// nolint:bodyclose
|
||||
ws, res, err := websocket.Dial(ctx, coordinateURL.String(), &websocket.DialOptions{
|
||||
HTTPClient: httpClient,
|
||||
HTTPClient: c.HTTPClient,
|
||||
HTTPHeader: coordinateHeaders,
|
||||
// Need to disable compression to avoid a data-race.
|
||||
CompressionMode: websocket.CompressionDisabled,
|
||||
})
|
||||
|
||||
+21
-17
@@ -11,19 +11,10 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type CreateWorkspaceProxyRequest struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Icon string `json:"icon"`
|
||||
URL string `json:"url"`
|
||||
WildcardHostname string `json:"wildcard_hostname"`
|
||||
}
|
||||
|
||||
type WorkspaceProxy struct {
|
||||
ID uuid.UUID `db:"id" json:"id" format:"uuid"`
|
||||
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id" format:"uuid"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Icon string `db:"icon" json:"icon"`
|
||||
ID uuid.UUID `db:"id" json:"id" format:"uuid"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Icon string `db:"icon" json:"icon"`
|
||||
// Full url including scheme of the proxy api url: https://us.example.com
|
||||
URL string `db:"url" json:"url"`
|
||||
// WildcardHostname with the wildcard for subdomain based app hosting: *.us.example.com
|
||||
@@ -33,24 +24,37 @@ type WorkspaceProxy struct {
|
||||
Deleted bool `db:"deleted" json:"deleted"`
|
||||
}
|
||||
|
||||
func (c *Client) CreateWorkspaceProxy(ctx context.Context, req CreateWorkspaceProxyRequest) (WorkspaceProxy, error) {
|
||||
type CreateWorkspaceProxyRequest struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Icon string `json:"icon"`
|
||||
URL string `json:"url"`
|
||||
WildcardHostname string `json:"wildcard_hostname"`
|
||||
}
|
||||
|
||||
type CreateWorkspaceProxyResponse struct {
|
||||
Proxy WorkspaceProxy `json:"proxy"`
|
||||
ProxyToken string `json:"proxy_token"`
|
||||
}
|
||||
|
||||
func (c *Client) CreateWorkspaceProxy(ctx context.Context, req CreateWorkspaceProxyRequest) (CreateWorkspaceProxyResponse, error) {
|
||||
res, err := c.Request(ctx, http.MethodPost,
|
||||
"/api/v2/workspaceproxies",
|
||||
req,
|
||||
)
|
||||
if err != nil {
|
||||
return WorkspaceProxy{}, xerrors.Errorf("make request: %w", err)
|
||||
return CreateWorkspaceProxyResponse{}, xerrors.Errorf("make request: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusCreated {
|
||||
return WorkspaceProxy{}, ReadBodyAsError(res)
|
||||
return CreateWorkspaceProxyResponse{}, ReadBodyAsError(res)
|
||||
}
|
||||
var resp WorkspaceProxy
|
||||
var resp CreateWorkspaceProxyResponse
|
||||
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
||||
}
|
||||
|
||||
func (c *Client) WorkspaceProxiesByOrganization(ctx context.Context) ([]WorkspaceProxy, error) {
|
||||
func (c *Client) WorkspaceProxies(ctx context.Context) ([]WorkspaceProxy, error) {
|
||||
res, err := c.Request(ctx, http.MethodGet,
|
||||
"/api/v2/workspaceproxies",
|
||||
nil,
|
||||
|
||||
@@ -20,7 +20,7 @@ We track the following resources:
|
||||
| User<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>avatar_url</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>true</td></tr><tr><td>email</td><td>true</td></tr><tr><td>hashed_password</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>last_seen_at</td><td>false</td></tr><tr><td>login_type</td><td>false</td></tr><tr><td>rbac_roles</td><td>true</td></tr><tr><td>status</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>username</td><td>true</td></tr></tbody></table> |
|
||||
| Workspace<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>autostart_schedule</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>id</td><td>true</td></tr><tr><td>last_used_at</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>owner_id</td><td>true</td></tr><tr><td>template_id</td><td>true</td></tr><tr><td>ttl</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
|
||||
| WorkspaceBuild<br><i>start, stop</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>build_number</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>daily_cost</td><td>false</td></tr><tr><td>deadline</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>initiator_id</td><td>false</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>max_deadline</td><td>false</td></tr><tr><td>provisioner_state</td><td>false</td></tr><tr><td>reason</td><td>false</td></tr><tr><td>template_version_id</td><td>true</td></tr><tr><td>transition</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>workspace_id</td><td>false</td></tr></tbody></table> |
|
||||
| WorkspaceProxy<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>true</td></tr><tr><td>deleted</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>updated_at</td><td>true</td></tr><tr><td>url</td><td>true</td></tr><tr><td>wildcard_hostname</td><td>true</td></tr></tbody></table> |
|
||||
| WorkspaceProxy<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>true</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>token_hashed_secret</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>url</td><td>true</td></tr><tr><td>wildcard_hostname</td><td>true</td></tr></tbody></table> |
|
||||
|
||||
<!-- End generated by 'make docs/admin/audit-logs.md'. -->
|
||||
|
||||
|
||||
+57
-2
@@ -1185,7 +1185,6 @@ curl -X GET http://coder-server:8080/api/v2/workspaceproxies \
|
||||
"icon": "string",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"name": "string",
|
||||
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
|
||||
"updated_at": "2019-08-24T14:15:22Z",
|
||||
"url": "string",
|
||||
"wildcard_hostname": "string"
|
||||
@@ -1211,9 +1210,65 @@ Status Code **200**
|
||||
| `» icon` | string | false | | |
|
||||
| `» id` | string(uuid) | false | | |
|
||||
| `» name` | string | false | | |
|
||||
| `» organization_id` | string(uuid) | false | | |
|
||||
| `» updated_at` | string(date-time) | false | | |
|
||||
| `» url` | string | false | | Full URL including scheme of the proxy api url: https://us.example.com |
|
||||
| `» wildcard_hostname` | string | false | | Wildcard hostname with the wildcard for subdomain based app hosting: \*.us.example.com |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
## Create workspace proxy
|
||||
|
||||
### Code samples
|
||||
|
||||
```shell
|
||||
# Example request using curl
|
||||
curl -X POST http://coder-server:8080/api/v2/workspaceproxies \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Accept: application/json' \
|
||||
-H 'Coder-Session-Token: API_KEY'
|
||||
```
|
||||
|
||||
`POST /workspaceproxies`
|
||||
|
||||
> Body parameter
|
||||
|
||||
```json
|
||||
{
|
||||
"display_name": "string",
|
||||
"icon": "string",
|
||||
"name": "string",
|
||||
"url": "string",
|
||||
"wildcard_hostname": "string"
|
||||
}
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
| ------ | ---- | -------------------------------------------------------------------------------------- | -------- | ------------------------------ |
|
||||
| `body` | body | [codersdk.CreateWorkspaceProxyRequest](schemas.md#codersdkcreateworkspaceproxyrequest) | true | Create workspace proxy request |
|
||||
|
||||
### Example responses
|
||||
|
||||
> 201 Response
|
||||
|
||||
```json
|
||||
{
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"deleted": true,
|
||||
"icon": "string",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"name": "string",
|
||||
"updated_at": "2019-08-24T14:15:22Z",
|
||||
"url": "string",
|
||||
"wildcard_hostname": "string"
|
||||
}
|
||||
```
|
||||
|
||||
### Responses
|
||||
|
||||
| Status | Meaning | Description | Schema |
|
||||
| ------ | ------------------------------------------------------------ | ----------- | ------------------------------------------------------------ |
|
||||
| 201 | [Created](https://tools.ietf.org/html/rfc7231#section-6.3.2) | Created | [codersdk.WorkspaceProxy](schemas.md#codersdkworkspaceproxy) |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
+3
-1
@@ -53,8 +53,10 @@ curl -X GET http://coder-server:8080/api/v2/buildinfo \
|
||||
|
||||
```json
|
||||
{
|
||||
"dashboard_url": "string",
|
||||
"external_url": "string",
|
||||
"version": "string"
|
||||
"version": "string",
|
||||
"workspace_proxy": true
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
+94
-7
@@ -1141,17 +1141,21 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|
||||
|
||||
```json
|
||||
{
|
||||
"dashboard_url": "string",
|
||||
"external_url": "string",
|
||||
"version": "string"
|
||||
"version": "string",
|
||||
"workspace_proxy": true
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
| -------------- | ------ | -------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `external_url` | string | false | | External URL references the current Coder version. For production builds, this will link directly to a release. For development builds, this will link to a commit. |
|
||||
| `version` | string | false | | Version returns the semantic version of the build. |
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
| ----------------- | ------- | -------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `dashboard_url` | string | false | | Dashboard URL is the URL to hit the deployment's dashboard. For external workspace proxies, this is the coderd they are connected to. |
|
||||
| `external_url` | string | false | | External URL references the current Coder version. For production builds, this will link directly to a release. For development builds, this will link to a commit. |
|
||||
| `version` | string | false | | Version returns the semantic version of the build. |
|
||||
| `workspace_proxy` | boolean | false | | |
|
||||
|
||||
## codersdk.BuildReason
|
||||
|
||||
@@ -5162,7 +5166,6 @@ Parameter represents a set value for the scope.
|
||||
"icon": "string",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"name": "string",
|
||||
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
|
||||
"updated_at": "2019-08-24T14:15:22Z",
|
||||
"url": "string",
|
||||
"wildcard_hostname": "string"
|
||||
@@ -5178,7 +5181,6 @@ Parameter represents a set value for the scope.
|
||||
| `icon` | string | false | | |
|
||||
| `id` | string | false | | |
|
||||
| `name` | string | false | | |
|
||||
| `organization_id` | string | false | | |
|
||||
| `updated_at` | string | false | | |
|
||||
| `url` | string | false | | Full URL including scheme of the proxy api url: https://us.example.com |
|
||||
| `wildcard_hostname` | string | false | | Wildcard hostname with the wildcard for subdomain based app hosting: \*.us.example.com |
|
||||
@@ -6286,3 +6288,88 @@ RegionIDs in range 900-999 are reserved for end users to run their own DERP node
|
||||
### Properties
|
||||
|
||||
_None_
|
||||
|
||||
## workspaceapps.AccessMethod
|
||||
|
||||
```json
|
||||
"path"
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
#### Enumerated Values
|
||||
|
||||
| Value |
|
||||
| ----------- |
|
||||
| `path` |
|
||||
| `subdomain` |
|
||||
| `terminal` |
|
||||
|
||||
## workspaceapps.IssueTokenRequest
|
||||
|
||||
```json
|
||||
{
|
||||
"app_hostname": "string",
|
||||
"app_path": "string",
|
||||
"app_query": "string",
|
||||
"app_request": {
|
||||
"access_method": "path",
|
||||
"agent_name_or_id": "string",
|
||||
"app_slug_or_port": "string",
|
||||
"base_path": "string",
|
||||
"username_or_id": "string",
|
||||
"workspace_name_or_id": "string"
|
||||
},
|
||||
"path_app_base_url": "string",
|
||||
"session_token": "string"
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
| ------------------- | ---------------------------------------------- | -------- | ------------ | --------------------------------------------------------------------------------------------------------------- |
|
||||
| `app_hostname` | string | false | | App hostname is the optional hostname for subdomain apps on the external proxy. It must start with an asterisk. |
|
||||
| `app_path` | string | false | | App path is the path of the user underneath the app base path. |
|
||||
| `app_query` | string | false | | App query is the query parameters the user provided in the app request. |
|
||||
| `app_request` | [workspaceapps.Request](#workspaceappsrequest) | false | | |
|
||||
| `path_app_base_url` | string | false | | Path app base URL is required. |
|
||||
| `session_token` | string | false | | Session token is the session token provided by the user. |
|
||||
|
||||
## workspaceapps.Request
|
||||
|
||||
```json
|
||||
{
|
||||
"access_method": "path",
|
||||
"agent_name_or_id": "string",
|
||||
"app_slug_or_port": "string",
|
||||
"base_path": "string",
|
||||
"username_or_id": "string",
|
||||
"workspace_name_or_id": "string"
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
| ---------------------- | -------------------------------------------------------- | -------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `access_method` | [workspaceapps.AccessMethod](#workspaceappsaccessmethod) | false | | |
|
||||
| `agent_name_or_id` | string | false | | Agent name or ID is not required if the workspace has only one agent. |
|
||||
| `app_slug_or_port` | string | false | | |
|
||||
| `base_path` | string | false | | Base path of the app. For path apps, this is the path prefix in the router for this particular app. For subdomain apps, this should be "/". This is used for setting the cookie path. |
|
||||
| `username_or_id` | string | false | | For the following fields, if the AccessMethod is AccessMethodTerminal, then only AgentNameOrID may be set and it must be a UUID. The other fields must be left blank. |
|
||||
| `workspace_name_or_id` | string | false | | |
|
||||
|
||||
## wsproxysdk.IssueSignedAppTokenResponse
|
||||
|
||||
```json
|
||||
{
|
||||
"signed_token_str": "string"
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
| ------------------ | ------ | -------- | ------------ | ----------------------------------------------------------- |
|
||||
| `signed_token_str` | string | false | | Signed token str should be set as a cookie on the response. |
|
||||
|
||||
@@ -2472,61 +2472,3 @@ Status Code **200**
|
||||
| `type` | `bool` |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
## Create workspace proxy
|
||||
|
||||
### Code samples
|
||||
|
||||
```shell
|
||||
# Example request using curl
|
||||
curl -X POST http://coder-server:8080/api/v2/workspaceproxies \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Accept: application/json' \
|
||||
-H 'Coder-Session-Token: API_KEY'
|
||||
```
|
||||
|
||||
`POST /workspaceproxies`
|
||||
|
||||
> Body parameter
|
||||
|
||||
```json
|
||||
{
|
||||
"display_name": "string",
|
||||
"icon": "string",
|
||||
"name": "string",
|
||||
"url": "string",
|
||||
"wildcard_hostname": "string"
|
||||
}
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
| ------ | ---- | -------------------------------------------------------------------------------------- | -------- | ------------------------------ |
|
||||
| `body` | body | [codersdk.CreateWorkspaceProxyRequest](schemas.md#codersdkcreateworkspaceproxyrequest) | true | Create workspace proxy request |
|
||||
|
||||
### Example responses
|
||||
|
||||
> 201 Response
|
||||
|
||||
```json
|
||||
{
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"deleted": true,
|
||||
"icon": "string",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"name": "string",
|
||||
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
|
||||
"updated_at": "2019-08-24T14:15:22Z",
|
||||
"url": "string",
|
||||
"wildcard_hostname": "string"
|
||||
}
|
||||
```
|
||||
|
||||
### Responses
|
||||
|
||||
| Status | Meaning | Description | Schema |
|
||||
| ------ | ------------------------------------------------------------ | ----------- | ------------------------------------------------------------ |
|
||||
| 201 | [Created](https://tools.ietf.org/html/rfc7231#section-6.3.2) | Created | [codersdk.WorkspaceProxy](schemas.md#codersdkworkspaceproxy) |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
@@ -163,15 +163,16 @@ var auditableResourcesTypes = map[any]map[string]Action{
|
||||
"uuid": ActionTrack,
|
||||
},
|
||||
&database.WorkspaceProxy{}: {
|
||||
"id": ActionTrack,
|
||||
"name": ActionTrack,
|
||||
"display_name": ActionTrack,
|
||||
"icon": ActionTrack,
|
||||
"url": ActionTrack,
|
||||
"wildcard_hostname": ActionTrack,
|
||||
"created_at": ActionTrack,
|
||||
"updated_at": ActionTrack,
|
||||
"deleted": ActionTrack,
|
||||
"id": ActionTrack,
|
||||
"name": ActionTrack,
|
||||
"display_name": ActionTrack,
|
||||
"icon": ActionTrack,
|
||||
"url": ActionTrack,
|
||||
"wildcard_hostname": ActionTrack,
|
||||
"created_at": ActionTrack,
|
||||
"updated_at": ActionIgnore,
|
||||
"deleted": ActionIgnore,
|
||||
"token_hashed_secret": ActionSecret,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -83,11 +83,24 @@ func New(ctx context.Context, options *Options) (*API, error) {
|
||||
})
|
||||
r.Route("/workspaceproxies", func(r chi.Router) {
|
||||
r.Use(
|
||||
apiKeyMiddleware,
|
||||
api.moonsEnabledMW,
|
||||
)
|
||||
r.Post("/", api.postWorkspaceProxy)
|
||||
r.Get("/", api.workspaceProxies)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(
|
||||
apiKeyMiddleware,
|
||||
)
|
||||
r.Post("/", api.postWorkspaceProxy)
|
||||
r.Get("/", api.workspaceProxies)
|
||||
})
|
||||
r.Route("/me", func(r chi.Router) {
|
||||
r.Use(
|
||||
httpmw.ExtractWorkspaceProxy(httpmw.ExtractWorkspaceProxyConfig{
|
||||
DB: options.Database,
|
||||
Optional: false,
|
||||
}),
|
||||
)
|
||||
r.Post("/issue-signed-app-token", api.workspaceProxyIssueSignedAppToken)
|
||||
})
|
||||
// TODO: Add specific workspace proxy endpoints.
|
||||
// r.Route("/{proxyName}", func(r chi.Router) {
|
||||
// r.Use(
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package coderdenttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/moby/moby/pkg/namesgenerator"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
"github.com/coder/coder/coderd/httpapi"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/enterprise/coderd"
|
||||
"github.com/coder/coder/enterprise/wsproxy"
|
||||
)
|
||||
|
||||
type ProxyOptions struct {
|
||||
Name string
|
||||
|
||||
TLSCertificates []tls.Certificate
|
||||
AppHostname string
|
||||
DisablePathApps bool
|
||||
|
||||
// ProxyURL is optional
|
||||
ProxyURL *url.URL
|
||||
}
|
||||
|
||||
// NewWorkspaceProxy will configure a wsproxy.Server with the given options.
|
||||
// The new wsproxy will register itself with the given coderd.API instance.
|
||||
// The first user owner client is required to create the wsproxy on the coderd
|
||||
// api server.
|
||||
func NewWorkspaceProxy(t *testing.T, coderdAPI *coderd.API, owner *codersdk.Client, options *ProxyOptions) *wsproxy.Server {
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancelFunc)
|
||||
|
||||
if options == nil {
|
||||
options = &ProxyOptions{}
|
||||
}
|
||||
|
||||
// HTTP Server
|
||||
var mutex sync.RWMutex
|
||||
var handler http.Handler
|
||||
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mutex.RLock()
|
||||
defer mutex.RUnlock()
|
||||
if handler == nil {
|
||||
http.Error(w, "handler not set", http.StatusServiceUnavailable)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
}))
|
||||
srv.Config.BaseContext = func(_ net.Listener) context.Context {
|
||||
return ctx
|
||||
}
|
||||
if options.TLSCertificates != nil {
|
||||
srv.TLS = &tls.Config{
|
||||
Certificates: options.TLSCertificates,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
srv.StartTLS()
|
||||
} else {
|
||||
srv.Start()
|
||||
}
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
tcpAddr, ok := srv.Listener.Addr().(*net.TCPAddr)
|
||||
require.True(t, ok)
|
||||
|
||||
serverURL, err := url.Parse(srv.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
serverURL.Host = fmt.Sprintf("localhost:%d", tcpAddr.Port)
|
||||
|
||||
accessURL := options.ProxyURL
|
||||
if accessURL == nil {
|
||||
accessURL = serverURL
|
||||
}
|
||||
|
||||
// TODO: Stun and derp stuff
|
||||
// derpPort, err := strconv.Atoi(serverURL.Port())
|
||||
// require.NoError(t, err)
|
||||
//
|
||||
// stunAddr, stunCleanup := stuntest.ServeWithPacketListener(t, nettype.Std{})
|
||||
// t.Cleanup(stunCleanup)
|
||||
//
|
||||
// derpServer := derp.NewServer(key.NewNode(), tailnet.Logger(slogtest.Make(t, nil).Named("derp").Leveled(slog.LevelDebug)))
|
||||
// derpServer.SetMeshKey("test-key")
|
||||
|
||||
var appHostnameRegex *regexp.Regexp
|
||||
if options.AppHostname != "" {
|
||||
var err error
|
||||
appHostnameRegex, err = httpapi.CompileHostnamePattern(options.AppHostname)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
if options.Name == "" {
|
||||
options.Name = namesgenerator.GetRandomName(1)
|
||||
}
|
||||
|
||||
proxyRes, err := owner.CreateWorkspaceProxy(ctx, codersdk.CreateWorkspaceProxyRequest{
|
||||
Name: options.Name,
|
||||
Icon: "/emojis/flag.png",
|
||||
URL: accessURL.String(),
|
||||
WildcardHostname: options.AppHostname,
|
||||
})
|
||||
require.NoError(t, err, "failed to create workspace proxy")
|
||||
|
||||
wssrv, err := wsproxy.New(&wsproxy.Options{
|
||||
Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug),
|
||||
DashboardURL: coderdAPI.AccessURL,
|
||||
AccessURL: accessURL,
|
||||
AppHostname: options.AppHostname,
|
||||
AppHostnameRegex: appHostnameRegex,
|
||||
RealIPConfig: coderdAPI.RealIPConfig,
|
||||
AppSecurityKey: coderdAPI.AppSecurityKey,
|
||||
Tracing: coderdAPI.TracerProvider,
|
||||
APIRateLimit: coderdAPI.APIRateLimit,
|
||||
SecureAuthCookie: coderdAPI.SecureAuthCookie,
|
||||
ProxySessionToken: proxyRes.ProxyToken,
|
||||
DisablePathApps: options.DisablePathApps,
|
||||
// We need a new registry to not conflict with the coderd internal
|
||||
// proxy metrics.
|
||||
PrometheusRegistry: prometheus.NewRegistry(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
mutex.Lock()
|
||||
handler = wssrv.Handler
|
||||
mutex.Unlock()
|
||||
|
||||
return wssrv
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package coderd
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -12,7 +13,10 @@ import (
|
||||
"github.com/coder/coder/coderd/audit"
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/httpapi"
|
||||
"github.com/coder/coder/coderd/workspaceapps"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/cryptorand"
|
||||
"github.com/coder/coder/enterprise/wsproxy/wsproxysdk"
|
||||
)
|
||||
|
||||
// @Summary Create workspace proxy
|
||||
@@ -20,7 +24,7 @@ import (
|
||||
// @Security CoderSessionToken
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Tags Templates
|
||||
// @Tags Enterprise
|
||||
// @Param request body codersdk.CreateWorkspaceProxyRequest true "Create workspace proxy request"
|
||||
// @Success 201 {object} codersdk.WorkspaceProxy
|
||||
// @Router /workspaceproxies [post]
|
||||
@@ -50,23 +54,35 @@ func (api *API) postWorkspaceProxy(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := httpapi.CompileHostnamePattern(req.WildcardHostname); err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Wildcard URL is invalid.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
if req.WildcardHostname != "" {
|
||||
if _, err := httpapi.CompileHostnamePattern(req.WildcardHostname); err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Wildcard URL is invalid.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
id := uuid.New()
|
||||
secret, err := cryptorand.HexString(64)
|
||||
if err != nil {
|
||||
httpapi.InternalServerError(rw, err)
|
||||
return
|
||||
}
|
||||
hashedSecret := sha256.Sum256([]byte(secret))
|
||||
fullToken := fmt.Sprintf("%s:%s", id, secret)
|
||||
|
||||
proxy, err := api.Database.InsertWorkspaceProxy(ctx, database.InsertWorkspaceProxyParams{
|
||||
ID: uuid.New(),
|
||||
Name: req.Name,
|
||||
DisplayName: req.DisplayName,
|
||||
Icon: req.Icon,
|
||||
Url: req.URL,
|
||||
WildcardHostname: req.WildcardHostname,
|
||||
CreatedAt: database.Now(),
|
||||
UpdatedAt: database.Now(),
|
||||
ID: id,
|
||||
Name: req.Name,
|
||||
DisplayName: req.DisplayName,
|
||||
Icon: req.Icon,
|
||||
Url: req.URL,
|
||||
WildcardHostname: req.WildcardHostname,
|
||||
TokenHashedSecret: hashedSecret[:],
|
||||
CreatedAt: database.Now(),
|
||||
UpdatedAt: database.Now(),
|
||||
})
|
||||
if database.IsUniqueViolation(err) {
|
||||
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
|
||||
@@ -80,7 +96,10 @@ func (api *API) postWorkspaceProxy(rw http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
aReq.New = proxy
|
||||
httpapi.Write(ctx, rw, http.StatusCreated, convertProxy(proxy))
|
||||
httpapi.Write(ctx, rw, http.StatusCreated, codersdk.CreateWorkspaceProxyResponse{
|
||||
Proxy: convertProxy(proxy),
|
||||
ProxyToken: fullToken,
|
||||
})
|
||||
}
|
||||
|
||||
// nolint:revive
|
||||
@@ -137,3 +156,55 @@ func convertProxy(p database.WorkspaceProxy) codersdk.WorkspaceProxy {
|
||||
Deleted: p.Deleted,
|
||||
}
|
||||
}
|
||||
|
||||
// @Summary Issue signed workspace app token
|
||||
// @ID issue-signed-workspace-app-token
|
||||
// @Security CoderSessionToken
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Tags Enterprise
|
||||
// @Param request body workspaceapps.IssueTokenRequest true "Issue signed app token request"
|
||||
// @Success 201 {object} wsproxysdk.IssueSignedAppTokenResponse
|
||||
// @Router /workspaceproxies/me/issue-signed-app-token [post]
|
||||
// @x-apidocgen {"skip": true}
|
||||
func (api *API) workspaceProxyIssueSignedAppToken(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
// NOTE: this endpoint will return JSON on success, but will (usually)
|
||||
// return a self-contained HTML error page on failure. The external proxy
|
||||
// should forward any non-201 response to the client.
|
||||
|
||||
var req workspaceapps.IssueTokenRequest
|
||||
if !httpapi.Read(ctx, rw, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
// userReq is a http request from the user on the other side of the proxy.
|
||||
// Although the workspace proxy is making this call, we want to use the user's
|
||||
// authorization context to create the token.
|
||||
//
|
||||
// We can use the existing request context for all tracing/logging purposes.
|
||||
// Any workspace proxy auth uses different context keys so we don't need to
|
||||
// worry about that.
|
||||
userReq, err := http.NewRequestWithContext(ctx, "GET", req.AppRequest.BasePath, nil)
|
||||
if err != nil {
|
||||
// This should never happen
|
||||
httpapi.InternalServerError(rw, xerrors.Errorf("[DEV ERROR] new request: %w", err))
|
||||
return
|
||||
}
|
||||
userReq.Header.Set(codersdk.SessionTokenHeader, req.SessionToken)
|
||||
|
||||
// Exchange the token.
|
||||
token, tokenStr, ok := api.AGPL.WorkspaceAppsProvider.Issue(ctx, rw, userReq, req)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if token == nil {
|
||||
httpapi.InternalServerError(rw, xerrors.New("nil token after calling token provider"))
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusCreated, wsproxysdk.IssueSignedAppTokenResponse{
|
||||
SignedTokenStr: tokenStr,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
package coderd_test
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"net/http/httputil"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/moby/moby/pkg/namesgenerator"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
"github.com/coder/coder/agent"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/coderd/database/dbtestutil"
|
||||
"github.com/coder/coder/coderd/workspaceapps"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/codersdk/agentsdk"
|
||||
"github.com/coder/coder/enterprise/coderd/coderdenttest"
|
||||
"github.com/coder/coder/enterprise/coderd/license"
|
||||
"github.com/coder/coder/enterprise/wsproxy/wsproxysdk"
|
||||
"github.com/coder/coder/provisioner/echo"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
|
||||
@@ -36,7 +48,7 @@ func TestWorkspaceProxyCRUD(t *testing.T) {
|
||||
},
|
||||
})
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
proxy, err := client.CreateWorkspaceProxy(ctx, codersdk.CreateWorkspaceProxyRequest{
|
||||
proxyRes, err := client.CreateWorkspaceProxy(ctx, codersdk.CreateWorkspaceProxyRequest{
|
||||
Name: namesgenerator.GetRandomName(1),
|
||||
Icon: "/emojis/flag.png",
|
||||
URL: "https://" + namesgenerator.GetRandomName(1) + ".com",
|
||||
@@ -44,9 +56,117 @@ func TestWorkspaceProxyCRUD(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
proxies, err := client.WorkspaceProxiesByOrganization(ctx)
|
||||
proxies, err := client.WorkspaceProxies(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, proxies, 1)
|
||||
require.Equal(t, proxy, proxies[0])
|
||||
require.Equal(t, proxyRes.Proxy, proxies[0])
|
||||
require.NotEmpty(t, proxyRes.ProxyToken)
|
||||
})
|
||||
}
|
||||
|
||||
func TestIssueSignedAppToken(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
|
||||
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)
|
||||
|
||||
proxyClient := wsproxysdk.New(client.URL)
|
||||
proxyClient.SetSessionToken(proxyRes.ProxyToken)
|
||||
|
||||
t.Run("BadAppRequest", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
_, err = proxyClient.IssueSignedAppToken(ctx, workspaceapps.IssueTokenRequest{
|
||||
// Invalid request.
|
||||
AppRequest: workspaceapps.Request{},
|
||||
SessionToken: client.SessionToken(),
|
||||
})
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
goodRequest := workspaceapps.IssueTokenRequest{
|
||||
AppRequest: workspaceapps.Request{
|
||||
BasePath: "/app",
|
||||
AccessMethod: workspaceapps.AccessMethodTerminal,
|
||||
AgentNameOrID: build.Resources[0].Agents[0].ID.String(),
|
||||
},
|
||||
SessionToken: client.SessionToken(),
|
||||
}
|
||||
t.Run("OK", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
_, err = proxyClient.IssueSignedAppToken(ctx, goodRequest)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("OKHTML", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rw := httptest.NewRecorder()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
_, ok := proxyClient.IssueSignedAppTokenHTML(ctx, rw, goodRequest)
|
||||
if !assert.True(t, ok, "expected true") {
|
||||
resp := rw.Result()
|
||||
defer resp.Body.Close()
|
||||
dump, err := httputil.DumpResponse(resp, true)
|
||||
require.NoError(t, err)
|
||||
t.Log(string(dump))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package wsproxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"cdr.dev/slog"
|
||||
|
||||
"github.com/coder/coder/coderd/workspaceapps"
|
||||
"github.com/coder/coder/enterprise/wsproxy/wsproxysdk"
|
||||
)
|
||||
|
||||
var _ workspaceapps.SignedTokenProvider = (*TokenProvider)(nil)
|
||||
|
||||
type TokenProvider struct {
|
||||
DashboardURL *url.URL
|
||||
AccessURL *url.URL
|
||||
AppHostname string
|
||||
|
||||
Client *wsproxysdk.Client
|
||||
SecurityKey workspaceapps.SecurityKey
|
||||
Logger slog.Logger
|
||||
}
|
||||
|
||||
func (p *TokenProvider) FromRequest(r *http.Request) (*workspaceapps.SignedToken, bool) {
|
||||
return workspaceapps.FromRequest(r, p.SecurityKey)
|
||||
}
|
||||
|
||||
func (p *TokenProvider) Issue(ctx context.Context, rw http.ResponseWriter, r *http.Request, issueReq workspaceapps.IssueTokenRequest) (*workspaceapps.SignedToken, string, bool) {
|
||||
appReq := issueReq.AppRequest.Normalize()
|
||||
err := appReq.Validate()
|
||||
if err != nil {
|
||||
workspaceapps.WriteWorkspaceApp500(p.Logger, p.DashboardURL, rw, r, &appReq, err, "invalid app request")
|
||||
return nil, "", false
|
||||
}
|
||||
issueReq.AppRequest = appReq
|
||||
|
||||
resp, ok := p.Client.IssueSignedAppTokenHTML(ctx, rw, issueReq)
|
||||
if !ok {
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
// Check that it verifies properly and matches the string.
|
||||
token, err := p.SecurityKey.VerifySignedToken(resp.SignedTokenStr)
|
||||
if err != nil {
|
||||
workspaceapps.WriteWorkspaceApp500(p.Logger, p.DashboardURL, rw, r, &appReq, err, "failed to verify newly generated signed token")
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
// Check that it matches the request.
|
||||
if !token.MatchesRequest(appReq) {
|
||||
workspaceapps.WriteWorkspaceApp500(p.Logger, p.DashboardURL, rw, r, &appReq, err, "newly generated signed token does not match request")
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
return &token, resp.SignedTokenStr, true
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package wsproxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/coder/coder/coderd/httpapi"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/buildinfo"
|
||||
"github.com/coder/coder/coderd/httpmw"
|
||||
"github.com/coder/coder/coderd/tracing"
|
||||
"github.com/coder/coder/coderd/workspaceapps"
|
||||
"github.com/coder/coder/coderd/wsconncache"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/enterprise/wsproxy/wsproxysdk"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
Logger slog.Logger
|
||||
|
||||
// DashboardURL is the URL of the primary coderd instance.
|
||||
DashboardURL *url.URL
|
||||
// AccessURL is the URL of the WorkspaceProxy. This is the url to communicate
|
||||
// with this server.
|
||||
AccessURL *url.URL
|
||||
|
||||
// TODO: @emyrk We use these two fields in many places with this comment.
|
||||
// Maybe we should make some shared options struct?
|
||||
// AppHostname should be the wildcard hostname to use for workspace
|
||||
// applications INCLUDING the asterisk, (optional) suffix and leading dot.
|
||||
// It will use the same scheme and port number as the access URL.
|
||||
// E.g. "*.apps.coder.com" or "*-apps.coder.com".
|
||||
AppHostname string
|
||||
// AppHostnameRegex contains the regex version of options.AppHostname as
|
||||
// generated by httpapi.CompileHostnamePattern(). It MUST be set if
|
||||
// options.AppHostname is set.
|
||||
AppHostnameRegex *regexp.Regexp
|
||||
|
||||
RealIPConfig *httpmw.RealIPConfig
|
||||
// TODO: @emyrk this key needs to be provided via a file or something?
|
||||
// Maybe we should curl it from the primary over some secure connection?
|
||||
AppSecurityKey workspaceapps.SecurityKey
|
||||
|
||||
Tracing trace.TracerProvider
|
||||
PrometheusRegistry *prometheus.Registry
|
||||
|
||||
APIRateLimit int
|
||||
SecureAuthCookie bool
|
||||
DisablePathApps bool
|
||||
|
||||
ProxySessionToken string
|
||||
}
|
||||
|
||||
func (o *Options) Validate() error {
|
||||
var errs optErrors
|
||||
|
||||
errs.Required("Logger", o.Logger)
|
||||
errs.Required("DashboardURL", o.DashboardURL)
|
||||
errs.Required("AccessURL", o.AccessURL)
|
||||
errs.Required("RealIPConfig", o.RealIPConfig)
|
||||
errs.Required("PrometheusRegistry", o.PrometheusRegistry)
|
||||
errs.NotEmpty("ProxySessionToken", o.ProxySessionToken)
|
||||
errs.NotEmpty("AppSecurityKey", o.AppSecurityKey)
|
||||
|
||||
if len(errs) > 0 {
|
||||
return errs
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Server is an external workspace proxy server. This server can communicate
|
||||
// directly with a workspace. It requires a primary coderd to establish a said
|
||||
// connection.
|
||||
type Server struct {
|
||||
Options *Options
|
||||
Handler chi.Router
|
||||
|
||||
DashboardURL *url.URL
|
||||
AppServer *workspaceapps.Server
|
||||
|
||||
// Logging/Metrics
|
||||
Logger slog.Logger
|
||||
TracerProvider trace.TracerProvider
|
||||
PrometheusRegistry *prometheus.Registry
|
||||
|
||||
// SDKClient is a client to the primary coderd instance authenticated with
|
||||
// the moon's token.
|
||||
SDKClient *wsproxysdk.Client
|
||||
|
||||
// TODO: Missing:
|
||||
// - derpserver
|
||||
|
||||
// Used for graceful shutdown. Required for the dialer.
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func New(opts *Options) (*Server, error) {
|
||||
if opts.PrometheusRegistry == nil {
|
||||
opts.PrometheusRegistry = prometheus.NewRegistry()
|
||||
}
|
||||
|
||||
if err := opts.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// TODO: implement some ping and registration logic
|
||||
client := wsproxysdk.New(opts.DashboardURL)
|
||||
err := client.SetSessionToken(opts.ProxySessionToken)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("set client token: %w", err)
|
||||
}
|
||||
|
||||
r := chi.NewRouter()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := &Server{
|
||||
Options: opts,
|
||||
Handler: r,
|
||||
DashboardURL: opts.DashboardURL,
|
||||
Logger: opts.Logger.Named("workspace-proxy"),
|
||||
TracerProvider: opts.Tracing,
|
||||
PrometheusRegistry: opts.PrometheusRegistry,
|
||||
SDKClient: client,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
s.AppServer = &workspaceapps.Server{
|
||||
Logger: opts.Logger.Named("workspaceapps"),
|
||||
DashboardURL: opts.DashboardURL,
|
||||
AccessURL: opts.AccessURL,
|
||||
Hostname: opts.AppHostname,
|
||||
HostnameRegex: opts.AppHostnameRegex,
|
||||
RealIPConfig: opts.RealIPConfig,
|
||||
SignedTokenProvider: &TokenProvider{
|
||||
DashboardURL: opts.DashboardURL,
|
||||
AccessURL: opts.AccessURL,
|
||||
AppHostname: opts.AppHostname,
|
||||
Client: client,
|
||||
SecurityKey: s.Options.AppSecurityKey,
|
||||
Logger: s.Logger.Named("proxy_token_provider"),
|
||||
},
|
||||
WorkspaceConnCache: wsconncache.New(s.DialWorkspaceAgent, 0),
|
||||
AppSecurityKey: opts.AppSecurityKey,
|
||||
|
||||
DisablePathApps: opts.DisablePathApps,
|
||||
SecureAuthCookie: opts.SecureAuthCookie,
|
||||
}
|
||||
|
||||
// Routes
|
||||
apiRateLimiter := httpmw.RateLimit(opts.APIRateLimit, time.Minute)
|
||||
// Persistent middlewares to all routes
|
||||
r.Use(
|
||||
// TODO: @emyrk Should we standardize these in some other package?
|
||||
httpmw.Recover(s.Logger),
|
||||
tracing.StatusWriterMiddleware,
|
||||
tracing.Middleware(s.TracerProvider),
|
||||
httpmw.AttachRequestID,
|
||||
httpmw.ExtractRealIP(s.Options.RealIPConfig),
|
||||
httpmw.Logger(s.Logger),
|
||||
httpmw.Prometheus(s.PrometheusRegistry),
|
||||
|
||||
// HandleSubdomain is a middleware that handles all requests to the
|
||||
// subdomain-based workspace apps.
|
||||
s.AppServer.HandleSubdomain(apiRateLimiter),
|
||||
// Build-Version is helpful for debugging.
|
||||
func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Add("X-Coder-Build-Version", buildinfo.Version())
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
},
|
||||
// This header stops a browser from trying to MIME-sniff the content type and
|
||||
// forces it to stick with the declared content-type. This is the only valid
|
||||
// value for this header.
|
||||
// See: https://github.com/coder/security/issues/12
|
||||
func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Add("X-Content-Type-Options", "nosniff")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
},
|
||||
// TODO: @emyrk we might not need this? But good to have if it does
|
||||
// not break anything.
|
||||
httpmw.CSRF(s.Options.SecureAuthCookie),
|
||||
)
|
||||
|
||||
// Attach workspace apps routes.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(apiRateLimiter)
|
||||
s.AppServer.Attach(r)
|
||||
})
|
||||
|
||||
r.Get("/buildinfo", s.buildInfo)
|
||||
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("OK")) })
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Server) Close() error {
|
||||
s.cancel()
|
||||
return s.AppServer.Close()
|
||||
}
|
||||
|
||||
func (s *Server) DialWorkspaceAgent(id uuid.UUID) (*codersdk.WorkspaceAgentConn, error) {
|
||||
return s.SDKClient.DialWorkspaceAgent(s.ctx, id, nil)
|
||||
}
|
||||
|
||||
func (s *Server) buildInfo(rw http.ResponseWriter, r *http.Request) {
|
||||
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.BuildInfoResponse{
|
||||
ExternalURL: buildinfo.ExternalURL(),
|
||||
Version: buildinfo.Version(),
|
||||
DashboardURL: s.DashboardURL.String(),
|
||||
})
|
||||
}
|
||||
|
||||
type optErrors []error
|
||||
|
||||
func (e optErrors) Error() string {
|
||||
var b strings.Builder
|
||||
for _, err := range e {
|
||||
_, _ = b.WriteString(err.Error())
|
||||
_, _ = b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (e *optErrors) Required(name string, v any) {
|
||||
if v == nil {
|
||||
*e = append(*e, xerrors.Errorf("%s is required, got <nil>", name))
|
||||
}
|
||||
}
|
||||
|
||||
func (e *optErrors) NotEmpty(name string, v any) {
|
||||
if reflect.ValueOf(v).IsZero() {
|
||||
*e = append(*e, xerrors.Errorf("%s is required, got the zero value", name))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package wsproxy_test
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/coder/coder/cli/clibase"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/coderd/httpmw"
|
||||
"github.com/coder/coder/coderd/workspaceapps/apptest"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/enterprise/coderd/coderdenttest"
|
||||
"github.com/coder/coder/enterprise/coderd/license"
|
||||
)
|
||||
|
||||
func TestWorkspaceProxyWorkspaceApps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
apptest.Run(t, func(t *testing.T, opts *apptest.DeploymentOptions) *apptest.Deployment {
|
||||
deploymentValues := coderdtest.DeploymentValues(t)
|
||||
deploymentValues.DisablePathApps = clibase.Bool(opts.DisablePathApps)
|
||||
deploymentValues.Dangerous.AllowPathAppSharing = clibase.Bool(opts.DangerousAllowPathAppSharing)
|
||||
deploymentValues.Dangerous.AllowPathAppSiteOwnerAccess = clibase.Bool(opts.DangerousAllowPathAppSiteOwnerAccess)
|
||||
deploymentValues.Experiments = []string{
|
||||
string(codersdk.ExperimentMoons),
|
||||
"*",
|
||||
}
|
||||
|
||||
client, _, api := coderdenttest.NewWithAPI(t, &coderdenttest.Options{
|
||||
Options: &coderdtest.Options{
|
||||
DeploymentValues: deploymentValues,
|
||||
AppHostname: "*.primary.test.coder.com",
|
||||
IncludeProvisionerDaemon: true,
|
||||
RealIPConfig: &httpmw.RealIPConfig{
|
||||
TrustedOrigins: []*net.IPNet{{
|
||||
IP: net.ParseIP("127.0.0.1"),
|
||||
Mask: net.CIDRMask(8, 32),
|
||||
}},
|
||||
TrustedHeaders: []string{
|
||||
"CF-Connecting-IP",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
_ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
|
||||
Features: license.Features{
|
||||
codersdk.FeatureWorkspaceProxy: 1,
|
||||
},
|
||||
})
|
||||
|
||||
// Create the external proxy
|
||||
if opts.DisableSubdomainApps {
|
||||
opts.AppHost = ""
|
||||
}
|
||||
proxyAPI := coderdenttest.NewWorkspaceProxy(t, api, client, &coderdenttest.ProxyOptions{
|
||||
Name: "best-proxy",
|
||||
AppHostname: opts.AppHost,
|
||||
DisablePathApps: opts.DisablePathApps,
|
||||
})
|
||||
|
||||
return &apptest.Deployment{
|
||||
Options: opts,
|
||||
SDKClient: client,
|
||||
FirstUser: user,
|
||||
PathAppBaseURL: proxyAPI.Options.AccessURL,
|
||||
AppHostIsPrimary: false,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package wsproxysdk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/coderd/httpmw"
|
||||
"github.com/coder/coder/coderd/workspaceapps"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
// Client is a HTTP client for a subset of Coder API routes that external
|
||||
// proxies need.
|
||||
type Client struct {
|
||||
SDKClient *codersdk.Client
|
||||
// HACK: the issue-signed-app-token requests may issue redirect responses
|
||||
// (which need to be forwarded to the client), so the client we use to make
|
||||
// those requests must ignore redirects.
|
||||
sdkClientIgnoreRedirects *codersdk.Client
|
||||
}
|
||||
|
||||
// New creates a external proxy client for the provided primary coder server
|
||||
// URL.
|
||||
func New(serverURL *url.URL) *Client {
|
||||
sdkClient := codersdk.New(serverURL)
|
||||
sdkClient.SessionTokenHeader = httpmw.WorkspaceProxyAuthTokenHeader
|
||||
|
||||
sdkClientIgnoreRedirects := codersdk.New(serverURL)
|
||||
sdkClientIgnoreRedirects.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
sdkClientIgnoreRedirects.SessionTokenHeader = httpmw.WorkspaceProxyAuthTokenHeader
|
||||
|
||||
return &Client{
|
||||
SDKClient: sdkClient,
|
||||
sdkClientIgnoreRedirects: sdkClientIgnoreRedirects,
|
||||
}
|
||||
}
|
||||
|
||||
// SetSessionToken sets the session token for the client. An error is returned
|
||||
// if the session token is not in the correct format for external proxies.
|
||||
func (c *Client) SetSessionToken(token string) error {
|
||||
c.SDKClient.SetSessionToken(token)
|
||||
c.sdkClientIgnoreRedirects.SetSessionToken(token)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SessionToken returns the currently set token for the client.
|
||||
func (c *Client) SessionToken() string {
|
||||
return c.SDKClient.SessionToken()
|
||||
}
|
||||
|
||||
// Request wraps the underlying codersdk.Client's Request method.
|
||||
func (c *Client) Request(ctx context.Context, method, path string, body interface{}, opts ...codersdk.RequestOption) (*http.Response, error) {
|
||||
return c.SDKClient.Request(ctx, method, path, body, opts...)
|
||||
}
|
||||
|
||||
// RequestIgnoreRedirects wraps the underlying codersdk.Client's Request method
|
||||
// on the client that ignores redirects.
|
||||
func (c *Client) RequestIgnoreRedirects(ctx context.Context, method, path string, body interface{}, opts ...codersdk.RequestOption) (*http.Response, error) {
|
||||
return c.sdkClientIgnoreRedirects.Request(ctx, method, path, body, opts...)
|
||||
}
|
||||
|
||||
// DialWorkspaceAgent calls the underlying codersdk.Client's DialWorkspaceAgent
|
||||
// method.
|
||||
func (c *Client) DialWorkspaceAgent(ctx context.Context, agentID uuid.UUID, options *codersdk.DialWorkspaceAgentOptions) (agentConn *codersdk.WorkspaceAgentConn, err error) {
|
||||
return c.SDKClient.DialWorkspaceAgent(ctx, agentID, options)
|
||||
}
|
||||
|
||||
type IssueSignedAppTokenResponse struct {
|
||||
// SignedTokenStr should be set as a cookie on the response.
|
||||
SignedTokenStr string `json:"signed_token_str"`
|
||||
}
|
||||
|
||||
// IssueSignedAppToken issues a new signed app token for the provided app
|
||||
// request. The error page will be returned as JSON. For use in external
|
||||
// proxies, use IssueSignedAppTokenHTML instead.
|
||||
func (c *Client) IssueSignedAppToken(ctx context.Context, req workspaceapps.IssueTokenRequest) (IssueSignedAppTokenResponse, error) {
|
||||
resp, err := c.RequestIgnoreRedirects(ctx, http.MethodPost, "/api/v2/workspaceproxies/me/issue-signed-app-token", req, func(r *http.Request) {
|
||||
// This forces any HTML error pages to be returned as JSON instead.
|
||||
r.Header.Set("Accept", "application/json")
|
||||
})
|
||||
if err != nil {
|
||||
return IssueSignedAppTokenResponse{}, xerrors.Errorf("make request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
return IssueSignedAppTokenResponse{}, codersdk.ReadBodyAsError(resp)
|
||||
}
|
||||
|
||||
var res IssueSignedAppTokenResponse
|
||||
return res, json.NewDecoder(resp.Body).Decode(&res)
|
||||
}
|
||||
|
||||
// IssueSignedAppTokenHTML issues a new signed app token for the provided app
|
||||
// request. The error page will be returned as HTML in most cases, and will be
|
||||
// written directly to the provided http.ResponseWriter.
|
||||
func (c *Client) IssueSignedAppTokenHTML(ctx context.Context, rw http.ResponseWriter, req workspaceapps.IssueTokenRequest) (IssueSignedAppTokenResponse, bool) {
|
||||
writeError := func(rw http.ResponseWriter, err error) {
|
||||
res := codersdk.Response{
|
||||
Message: "Internal server error",
|
||||
Detail: err.Error(),
|
||||
}
|
||||
rw.WriteHeader(http.StatusInternalServerError)
|
||||
_ = json.NewEncoder(rw).Encode(res)
|
||||
}
|
||||
|
||||
resp, err := c.RequestIgnoreRedirects(ctx, http.MethodPost, "/api/v2/workspaceproxies/me/issue-signed-app-token", req, func(r *http.Request) {
|
||||
r.Header.Set("Accept", "text/html")
|
||||
})
|
||||
if err != nil {
|
||||
writeError(rw, xerrors.Errorf("perform issue signed app token request: %w", err))
|
||||
return IssueSignedAppTokenResponse{}, false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
// Copy the response to the ResponseWriter.
|
||||
for k, v := range resp.Header {
|
||||
rw.Header()[k] = v
|
||||
}
|
||||
rw.WriteHeader(resp.StatusCode)
|
||||
_, err = io.Copy(rw, resp.Body)
|
||||
if err != nil {
|
||||
writeError(rw, xerrors.Errorf("copy response body: %w", err))
|
||||
}
|
||||
return IssueSignedAppTokenResponse{}, false
|
||||
}
|
||||
|
||||
var res IssueSignedAppTokenResponse
|
||||
err = json.NewDecoder(resp.Body).Decode(&res)
|
||||
if err != nil {
|
||||
writeError(rw, xerrors.Errorf("decode response body: %w", err))
|
||||
return IssueSignedAppTokenResponse{}, false
|
||||
}
|
||||
return res, true
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package wsproxysdk_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/coderd/httpmw"
|
||||
"github.com/coder/coder/coderd/workspaceapps"
|
||||
"github.com/coder/coder/enterprise/wsproxy/wsproxysdk"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
|
||||
func Test_IssueSignedAppTokenHTML(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("OK", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
expectedProxyToken = "hi:test"
|
||||
expectedAppReq = workspaceapps.Request{
|
||||
AccessMethod: workspaceapps.AccessMethodPath,
|
||||
BasePath: "/@user/workspace/apps/slug",
|
||||
UsernameOrID: "user",
|
||||
WorkspaceNameOrID: "workspace",
|
||||
AppSlugOrPort: "slug",
|
||||
}
|
||||
expectedSessionToken = "user-session-token"
|
||||
expectedSignedTokenStr = "signed-app-token"
|
||||
)
|
||||
var called int64
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt64(&called, 1)
|
||||
|
||||
assert.Equal(t, r.Method, http.MethodPost)
|
||||
assert.Equal(t, r.URL.Path, "/api/v2/workspaceproxies/me/issue-signed-app-token")
|
||||
assert.Equal(t, r.Header.Get(httpmw.WorkspaceProxyAuthTokenHeader), expectedProxyToken)
|
||||
|
||||
var req workspaceapps.IssueTokenRequest
|
||||
err := json.NewDecoder(r.Body).Decode(&req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, req.AppRequest, expectedAppReq)
|
||||
assert.Equal(t, req.SessionToken, expectedSessionToken)
|
||||
|
||||
rw.WriteHeader(http.StatusCreated)
|
||||
err = json.NewEncoder(rw).Encode(wsproxysdk.IssueSignedAppTokenResponse{
|
||||
SignedTokenStr: expectedSignedTokenStr,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
}))
|
||||
|
||||
u, err := url.Parse(srv.URL)
|
||||
require.NoError(t, err)
|
||||
client := wsproxysdk.New(u)
|
||||
client.SetSessionToken(expectedProxyToken)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
rw := newResponseRecorder()
|
||||
tokenRes, ok := client.IssueSignedAppTokenHTML(ctx, rw, workspaceapps.IssueTokenRequest{
|
||||
AppRequest: expectedAppReq,
|
||||
SessionToken: expectedSessionToken,
|
||||
})
|
||||
if !assert.True(t, ok) {
|
||||
t.Log("issue request failed when it should've succeeded")
|
||||
t.Log("response dump:")
|
||||
res := rw.Result()
|
||||
defer res.Body.Close()
|
||||
dump, err := httputil.DumpResponse(res, true)
|
||||
if err != nil {
|
||||
t.Logf("failed to dump response: %v", err)
|
||||
} else {
|
||||
t.Log(string(dump))
|
||||
}
|
||||
t.FailNow()
|
||||
}
|
||||
require.Equal(t, expectedSignedTokenStr, tokenRes.SignedTokenStr)
|
||||
require.False(t, rw.WasWritten())
|
||||
|
||||
require.EqualValues(t, called, 1)
|
||||
})
|
||||
|
||||
t.Run("Error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
expectedProxyToken = "hi:test"
|
||||
expectedResponseStatus = http.StatusBadRequest
|
||||
expectedResponseBody = "bad request"
|
||||
)
|
||||
var called int64
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt64(&called, 1)
|
||||
|
||||
assert.Equal(t, r.Method, http.MethodPost)
|
||||
assert.Equal(t, r.URL.Path, "/api/v2/workspaceproxies/me/issue-signed-app-token")
|
||||
assert.Equal(t, r.Header.Get(httpmw.WorkspaceProxyAuthTokenHeader), expectedProxyToken)
|
||||
|
||||
rw.WriteHeader(expectedResponseStatus)
|
||||
_, _ = rw.Write([]byte(expectedResponseBody))
|
||||
}))
|
||||
|
||||
u, err := url.Parse(srv.URL)
|
||||
require.NoError(t, err)
|
||||
client := wsproxysdk.New(u)
|
||||
_ = client.SetSessionToken(expectedProxyToken)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
rw := newResponseRecorder()
|
||||
tokenRes, ok := client.IssueSignedAppTokenHTML(ctx, rw, workspaceapps.IssueTokenRequest{
|
||||
AppRequest: workspaceapps.Request{},
|
||||
SessionToken: "user-session-token",
|
||||
})
|
||||
require.False(t, ok)
|
||||
require.Empty(t, tokenRes)
|
||||
require.True(t, rw.WasWritten())
|
||||
|
||||
res := rw.Result()
|
||||
defer res.Body.Close()
|
||||
require.Equal(t, expectedResponseStatus, res.StatusCode)
|
||||
body, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expectedResponseBody, string(body))
|
||||
|
||||
require.EqualValues(t, called, 1)
|
||||
})
|
||||
}
|
||||
|
||||
type ResponseRecorder struct {
|
||||
rw *httptest.ResponseRecorder
|
||||
wasWritten atomic.Bool
|
||||
}
|
||||
|
||||
var _ http.ResponseWriter = &ResponseRecorder{}
|
||||
|
||||
func newResponseRecorder() *ResponseRecorder {
|
||||
return &ResponseRecorder{
|
||||
rw: httptest.NewRecorder(),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ResponseRecorder) WasWritten() bool {
|
||||
return r.wasWritten.Load()
|
||||
}
|
||||
|
||||
func (r *ResponseRecorder) Result() *http.Response {
|
||||
return r.rw.Result()
|
||||
}
|
||||
|
||||
func (r *ResponseRecorder) Flush() {
|
||||
r.wasWritten.Store(true)
|
||||
r.rw.Flush()
|
||||
}
|
||||
|
||||
func (r *ResponseRecorder) Header() http.Header {
|
||||
// Usually when retrieving the headers for the response, it means you're
|
||||
// trying to write a header.
|
||||
r.wasWritten.Store(true)
|
||||
return r.rw.Header()
|
||||
}
|
||||
|
||||
func (r *ResponseRecorder) Write(b []byte) (int, error) {
|
||||
r.wasWritten.Store(true)
|
||||
return r.rw.Write(b)
|
||||
}
|
||||
|
||||
func (r *ResponseRecorder) WriteHeader(statusCode int) {
|
||||
r.wasWritten.Store(true)
|
||||
r.rw.WriteHeader(statusCode)
|
||||
}
|
||||
@@ -135,6 +135,8 @@ export type AuthorizationResponse = Record<string, boolean>
|
||||
export interface BuildInfoResponse {
|
||||
readonly external_url: string
|
||||
readonly version: string
|
||||
readonly dashboard_url: string
|
||||
readonly workspace_proxy: boolean
|
||||
}
|
||||
|
||||
// From codersdk/parameters.go
|
||||
@@ -262,6 +264,12 @@ export interface CreateWorkspaceProxyRequest {
|
||||
readonly wildcard_hostname: string
|
||||
}
|
||||
|
||||
// From codersdk/workspaceproxy.go
|
||||
export interface CreateWorkspaceProxyResponse {
|
||||
readonly proxy: WorkspaceProxy
|
||||
readonly proxy_token: string
|
||||
}
|
||||
|
||||
// From codersdk/organizations.go
|
||||
export interface CreateWorkspaceRequest {
|
||||
readonly template_id: string
|
||||
@@ -1218,7 +1226,6 @@ export interface WorkspaceOptions {
|
||||
// From codersdk/workspaceproxy.go
|
||||
export interface WorkspaceProxy {
|
||||
readonly id: string
|
||||
readonly organization_id: string
|
||||
readonly name: string
|
||||
readonly icon: string
|
||||
readonly url: string
|
||||
@@ -1228,6 +1235,12 @@ export interface WorkspaceProxy {
|
||||
readonly deleted: boolean
|
||||
}
|
||||
|
||||
// From codersdk/deployment.go
|
||||
export interface WorkspaceProxyBuildInfo {
|
||||
readonly workspace_proxy: boolean
|
||||
readonly dashboard_url: string
|
||||
}
|
||||
|
||||
// From codersdk/workspaces.go
|
||||
export interface WorkspaceQuota {
|
||||
readonly credits_consumed: number
|
||||
|
||||
Reference in New Issue
Block a user