mirror of https://github.com/coder/coder.git
chore: support signed token query param for web terminal (#7197)
* chore: add endpoint to get token for web terminal * chore: support signed token query param for web terminal
This commit is contained in:
parent
ac3c530283
commit
68667323f3
|
@ -159,6 +159,48 @@ const docTemplate = `{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/applications/reconnecting-pty-signed-token": {
|
||||||
|
"post": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"CoderSessionToken": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Applications Enterprise"
|
||||||
|
],
|
||||||
|
"summary": "Issue signed app token for reconnecting PTY",
|
||||||
|
"operationId": "issue-signed-app-token-for-reconnecting-pty",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "Issue reconnecting PTY signed token request",
|
||||||
|
"name": "request",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/codersdk.IssueReconnectingPTYSignedTokenRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/codersdk.IssueReconnectingPTYSignedTokenResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-apidocgen": {
|
||||||
|
"skip": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/audit": {
|
"/audit": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
|
@ -7451,6 +7493,31 @@ const docTemplate = `{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"codersdk.IssueReconnectingPTYSignedTokenRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"agentID",
|
||||||
|
"url"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"agentID": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"description": "URL is the URL of the reconnecting-pty endpoint you are connecting to.",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"codersdk.IssueReconnectingPTYSignedTokenResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"signed_token": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"codersdk.JobErrorCode": {
|
"codersdk.JobErrorCode": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
|
|
|
@ -131,6 +131,42 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/applications/reconnecting-pty-signed-token": {
|
||||||
|
"post": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"CoderSessionToken": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"consumes": ["application/json"],
|
||||||
|
"produces": ["application/json"],
|
||||||
|
"tags": ["Applications Enterprise"],
|
||||||
|
"summary": "Issue signed app token for reconnecting PTY",
|
||||||
|
"operationId": "issue-signed-app-token-for-reconnecting-pty",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "Issue reconnecting PTY signed token request",
|
||||||
|
"name": "request",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/codersdk.IssueReconnectingPTYSignedTokenRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/codersdk.IssueReconnectingPTYSignedTokenResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-apidocgen": {
|
||||||
|
"skip": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/audit": {
|
"/audit": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
|
@ -6682,6 +6718,28 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"codersdk.IssueReconnectingPTYSignedTokenRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["agentID", "url"],
|
||||||
|
"properties": {
|
||||||
|
"agentID": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"description": "URL is the URL of the reconnecting-pty endpoint you are connecting to.",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"codersdk.IssueReconnectingPTYSignedTokenResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"signed_token": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"codersdk.JobErrorCode": {
|
"codersdk.JobErrorCode": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["MISSING_TEMPLATE_PARAMETER", "REQUIRED_TEMPLATE_VARIABLES"],
|
"enum": ["MISSING_TEMPLATE_PARAMETER", "REQUIRED_TEMPLATE_VARIABLES"],
|
||||||
|
|
|
@ -1701,10 +1701,6 @@ func (q *querier) GetWorkspaceProxyByName(ctx context.Context, name string) (dat
|
||||||
return fetch(q.log, q.auth, q.db.GetWorkspaceProxyByName)(ctx, name)
|
return fetch(q.log, q.auth, q.db.GetWorkspaceProxyByName)(ctx, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
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)
|
return insert(q.log, q.auth, rbac.ResourceWorkspaceProxy, q.db.InsertWorkspaceProxy)(ctx, arg)
|
||||||
}
|
}
|
||||||
|
|
|
@ -438,3 +438,10 @@ func (q *querier) InsertParameterSchema(ctx context.Context, arg database.Insert
|
||||||
}
|
}
|
||||||
return q.db.InsertParameterSchema(ctx, arg)
|
return q.db.InsertParameterSchema(ctx, arg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (q *querier) GetWorkspaceProxyByHostname(ctx context.Context, params database.GetWorkspaceProxyByHostnameParams) (database.WorkspaceProxy, error) {
|
||||||
|
if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceSystem); err != nil {
|
||||||
|
return database.WorkspaceProxy{}, err
|
||||||
|
}
|
||||||
|
return q.db.GetWorkspaceProxyByHostname(ctx, params)
|
||||||
|
}
|
||||||
|
|
|
@ -24,7 +24,7 @@ import (
|
||||||
"github.com/coder/coder/coderd/util/slice"
|
"github.com/coder/coder/coderd/util/slice"
|
||||||
)
|
)
|
||||||
|
|
||||||
var validProxyByHostnameRegex = regexp.MustCompile(`^[a-zA-Z0-9.-]+$`)
|
var validProxyByHostnameRegex = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
|
||||||
|
|
||||||
// FakeDatabase is helpful for knowing if the underlying db is an in memory fake
|
// 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
|
// database. This is only in the databasefake package, so will only be used
|
||||||
|
@ -5142,34 +5142,36 @@ func (q *fakeQuerier) GetWorkspaceProxyByName(_ context.Context, name string) (d
|
||||||
return database.WorkspaceProxy{}, sql.ErrNoRows
|
return database.WorkspaceProxy{}, sql.ErrNoRows
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *fakeQuerier) GetWorkspaceProxyByHostname(_ context.Context, hostname string) (database.WorkspaceProxy, error) {
|
func (q *fakeQuerier) GetWorkspaceProxyByHostname(_ context.Context, params database.GetWorkspaceProxyByHostnameParams) (database.WorkspaceProxy, error) {
|
||||||
q.mutex.RLock()
|
q.mutex.RLock()
|
||||||
defer q.mutex.RUnlock()
|
defer q.mutex.RUnlock()
|
||||||
|
|
||||||
// Return zero rows if this is called with a non-sanitized hostname. The SQL
|
// Return zero rows if this is called with a non-sanitized hostname. The SQL
|
||||||
// version of this query does the same thing.
|
// version of this query does the same thing.
|
||||||
if !validProxyByHostnameRegex.MatchString(hostname) {
|
if !validProxyByHostnameRegex.MatchString(params.Hostname) {
|
||||||
return database.WorkspaceProxy{}, sql.ErrNoRows
|
return database.WorkspaceProxy{}, sql.ErrNoRows
|
||||||
}
|
}
|
||||||
|
|
||||||
// This regex matches the SQL version.
|
// This regex matches the SQL version.
|
||||||
accessURLRegex := regexp.MustCompile(`[^:]*://` + regexp.QuoteMeta(hostname) + `([:/]?.)*`)
|
accessURLRegex := regexp.MustCompile(`[^:]*://` + regexp.QuoteMeta(params.Hostname) + `([:/]?.)*`)
|
||||||
|
|
||||||
for _, proxy := range q.workspaceProxies {
|
for _, proxy := range q.workspaceProxies {
|
||||||
if proxy.Deleted {
|
if proxy.Deleted {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if accessURLRegex.MatchString(proxy.Url) {
|
if params.AllowAccessUrl && accessURLRegex.MatchString(proxy.Url) {
|
||||||
return proxy, nil
|
return proxy, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compile the app hostname regex. This is slow sadly.
|
// Compile the app hostname regex. This is slow sadly.
|
||||||
wildcardRegexp, err := httpapi.CompileHostnamePattern(proxy.WildcardHostname)
|
if params.AllowWildcardHostname {
|
||||||
if err != nil {
|
wildcardRegexp, err := httpapi.CompileHostnamePattern(proxy.WildcardHostname)
|
||||||
return database.WorkspaceProxy{}, xerrors.Errorf("compile hostname pattern %q for proxy %q (%s): %w", proxy.WildcardHostname, proxy.Name, proxy.ID.String(), err)
|
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
|
if _, ok := httpapi.ExecuteHostnamePattern(wildcardRegexp, params.Hostname); ok {
|
||||||
|
return proxy, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -160,44 +160,74 @@ func TestProxyByHostname(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
testHostname string
|
testHostname string
|
||||||
matchProxyName string
|
allowAccessURL bool
|
||||||
|
allowWildcardHost bool
|
||||||
|
matchProxyName string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "NoMatch",
|
name: "NoMatch",
|
||||||
testHostname: "test.com",
|
testHostname: "test.com",
|
||||||
matchProxyName: "",
|
allowAccessURL: true,
|
||||||
|
allowWildcardHost: true,
|
||||||
|
matchProxyName: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "MatchAccessURL",
|
name: "MatchAccessURL",
|
||||||
testHostname: "one.coder.com",
|
testHostname: "one.coder.com",
|
||||||
matchProxyName: "one",
|
allowAccessURL: true,
|
||||||
|
allowWildcardHost: true,
|
||||||
|
matchProxyName: "one",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "MatchWildcard",
|
name: "MatchWildcard",
|
||||||
testHostname: "something.wildcard.one.coder.com",
|
testHostname: "something.wildcard.one.coder.com",
|
||||||
matchProxyName: "one",
|
allowAccessURL: true,
|
||||||
|
allowWildcardHost: true,
|
||||||
|
matchProxyName: "one",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "MatchSuffix",
|
name: "MatchSuffix",
|
||||||
testHostname: "something--suffix.two.coder.com",
|
testHostname: "something--suffix.two.coder.com",
|
||||||
matchProxyName: "two",
|
allowAccessURL: true,
|
||||||
|
allowWildcardHost: true,
|
||||||
|
matchProxyName: "two",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "ValidateHostname/1",
|
name: "ValidateHostname/1",
|
||||||
testHostname: ".*ne.coder.com",
|
testHostname: ".*ne.coder.com",
|
||||||
matchProxyName: "",
|
allowAccessURL: true,
|
||||||
|
allowWildcardHost: true,
|
||||||
|
matchProxyName: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "ValidateHostname/2",
|
name: "ValidateHostname/2",
|
||||||
testHostname: "https://one.coder.com",
|
testHostname: "https://one.coder.com",
|
||||||
matchProxyName: "",
|
allowAccessURL: true,
|
||||||
|
allowWildcardHost: true,
|
||||||
|
matchProxyName: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "ValidateHostname/3",
|
name: "ValidateHostname/3",
|
||||||
testHostname: "one.coder.com:8080/hello",
|
testHostname: "one.coder.com:8080/hello",
|
||||||
matchProxyName: "",
|
allowAccessURL: true,
|
||||||
|
allowWildcardHost: true,
|
||||||
|
matchProxyName: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IgnoreAccessURLMatch",
|
||||||
|
testHostname: "one.coder.com",
|
||||||
|
allowAccessURL: false,
|
||||||
|
allowWildcardHost: true,
|
||||||
|
matchProxyName: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IgnoreWildcardMatch",
|
||||||
|
testHostname: "hi.wildcard.one.coder.com",
|
||||||
|
allowAccessURL: true,
|
||||||
|
allowWildcardHost: false,
|
||||||
|
matchProxyName: "",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -206,7 +236,11 @@ func TestProxyByHostname(t *testing.T) {
|
||||||
t.Run(c.name, func(t *testing.T) {
|
t.Run(c.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
proxy, err := db.GetWorkspaceProxyByHostname(context.Background(), c.testHostname)
|
proxy, err := db.GetWorkspaceProxyByHostname(context.Background(), database.GetWorkspaceProxyByHostnameParams{
|
||||||
|
Hostname: c.testHostname,
|
||||||
|
AllowAccessUrl: c.allowAccessURL,
|
||||||
|
AllowWildcardHostname: c.allowWildcardHost,
|
||||||
|
})
|
||||||
if c.matchProxyName == "" {
|
if c.matchProxyName == "" {
|
||||||
require.ErrorIs(t, err, sql.ErrNoRows)
|
require.ErrorIs(t, err, sql.ErrNoRows)
|
||||||
require.Empty(t, proxy)
|
require.Empty(t, proxy)
|
||||||
|
|
|
@ -156,7 +156,7 @@ type sqlcQuerier interface {
|
||||||
// The hostname must be sanitized to only contain [a-zA-Z0-9.-] before calling
|
// The hostname must be sanitized to only contain [a-zA-Z0-9.-] before calling
|
||||||
// this query. The scheme, port and path should be stripped.
|
// this query. The scheme, port and path should be stripped.
|
||||||
//
|
//
|
||||||
GetWorkspaceProxyByHostname(ctx context.Context, hostname string) (WorkspaceProxy, error)
|
GetWorkspaceProxyByHostname(ctx context.Context, arg GetWorkspaceProxyByHostnameParams) (WorkspaceProxy, error)
|
||||||
GetWorkspaceProxyByID(ctx context.Context, id uuid.UUID) (WorkspaceProxy, error)
|
GetWorkspaceProxyByID(ctx context.Context, id uuid.UUID) (WorkspaceProxy, error)
|
||||||
GetWorkspaceProxyByName(ctx context.Context, name string) (WorkspaceProxy, error)
|
GetWorkspaceProxyByName(ctx context.Context, name string) (WorkspaceProxy, error)
|
||||||
GetWorkspaceResourceByID(ctx context.Context, id uuid.UUID) (WorkspaceResource, error)
|
GetWorkspaceResourceByID(ctx context.Context, id uuid.UUID) (WorkspaceResource, error)
|
||||||
|
|
|
@ -165,44 +165,74 @@ func TestProxyByHostname(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
testHostname string
|
testHostname string
|
||||||
matchProxyName string
|
allowAccessURL bool
|
||||||
|
allowWildcardHost bool
|
||||||
|
matchProxyName string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "NoMatch",
|
name: "NoMatch",
|
||||||
testHostname: "test.com",
|
testHostname: "test.com",
|
||||||
matchProxyName: "",
|
allowAccessURL: true,
|
||||||
|
allowWildcardHost: true,
|
||||||
|
matchProxyName: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "MatchAccessURL",
|
name: "MatchAccessURL",
|
||||||
testHostname: "one.coder.com",
|
testHostname: "one.coder.com",
|
||||||
matchProxyName: "one",
|
allowAccessURL: true,
|
||||||
|
allowWildcardHost: true,
|
||||||
|
matchProxyName: "one",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "MatchWildcard",
|
name: "MatchWildcard",
|
||||||
testHostname: "something.wildcard.one.coder.com",
|
testHostname: "something.wildcard.one.coder.com",
|
||||||
matchProxyName: "one",
|
allowAccessURL: true,
|
||||||
|
allowWildcardHost: true,
|
||||||
|
matchProxyName: "one",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "MatchSuffix",
|
name: "MatchSuffix",
|
||||||
testHostname: "something--suffix.two.coder.com",
|
testHostname: "something--suffix.two.coder.com",
|
||||||
matchProxyName: "two",
|
allowAccessURL: true,
|
||||||
|
allowWildcardHost: true,
|
||||||
|
matchProxyName: "two",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "ValidateHostname/1",
|
name: "ValidateHostname/1",
|
||||||
testHostname: ".*ne.coder.com",
|
testHostname: ".*ne.coder.com",
|
||||||
matchProxyName: "",
|
allowAccessURL: true,
|
||||||
|
allowWildcardHost: true,
|
||||||
|
matchProxyName: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "ValidateHostname/2",
|
name: "ValidateHostname/2",
|
||||||
testHostname: "https://one.coder.com",
|
testHostname: "https://one.coder.com",
|
||||||
matchProxyName: "",
|
allowAccessURL: true,
|
||||||
|
allowWildcardHost: true,
|
||||||
|
matchProxyName: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "ValidateHostname/3",
|
name: "ValidateHostname/3",
|
||||||
testHostname: "one.coder.com:8080/hello",
|
testHostname: "one.coder.com:8080/hello",
|
||||||
matchProxyName: "",
|
allowAccessURL: true,
|
||||||
|
allowWildcardHost: true,
|
||||||
|
matchProxyName: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IgnoreAccessURLMatch",
|
||||||
|
testHostname: "one.coder.com",
|
||||||
|
allowAccessURL: false,
|
||||||
|
allowWildcardHost: true,
|
||||||
|
matchProxyName: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IgnoreWildcardMatch",
|
||||||
|
testHostname: "hi.wildcard.one.coder.com",
|
||||||
|
allowAccessURL: true,
|
||||||
|
allowWildcardHost: false,
|
||||||
|
matchProxyName: "",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -211,7 +241,11 @@ func TestProxyByHostname(t *testing.T) {
|
||||||
t.Run(c.name, func(t *testing.T) {
|
t.Run(c.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
proxy, err := db.GetWorkspaceProxyByHostname(context.Background(), c.testHostname)
|
proxy, err := db.GetWorkspaceProxyByHostname(context.Background(), database.GetWorkspaceProxyByHostnameParams{
|
||||||
|
Hostname: c.testHostname,
|
||||||
|
AllowAccessUrl: c.allowAccessURL,
|
||||||
|
AllowWildcardHostname: c.allowWildcardHost,
|
||||||
|
})
|
||||||
if c.matchProxyName == "" {
|
if c.matchProxyName == "" {
|
||||||
require.ErrorIs(t, err, sql.ErrNoRows)
|
require.ErrorIs(t, err, sql.ErrNoRows)
|
||||||
require.Empty(t, proxy)
|
require.Empty(t, proxy)
|
||||||
|
|
|
@ -2871,27 +2871,39 @@ WHERE
|
||||||
--
|
--
|
||||||
-- Periods don't need to be escaped because they're not special characters
|
-- Periods don't need to be escaped because they're not special characters
|
||||||
-- in SQL matches unlike regular expressions.
|
-- in SQL matches unlike regular expressions.
|
||||||
$1 :: text SIMILAR TO '[a-zA-Z0-9.-]+' AND
|
$1 :: text SIMILAR TO '[a-zA-Z0-9._-]+' AND
|
||||||
deleted = false AND
|
deleted = false AND
|
||||||
|
|
||||||
-- Validate that the hostname matches either the wildcard hostname or the
|
-- Validate that the hostname matches either the wildcard hostname or the
|
||||||
-- access URL (ignoring scheme, port and path).
|
-- access URL (ignoring scheme, port and path).
|
||||||
(
|
(
|
||||||
url SIMILAR TO '[^:]*://' || $1 :: text || '([:/]?%)*' OR
|
(
|
||||||
$1 :: text LIKE replace(wildcard_hostname, '*', '%')
|
$2 :: bool = true AND
|
||||||
|
url SIMILAR TO '[^:]*://' || $1 :: text || '([:/]?%)*'
|
||||||
|
) OR
|
||||||
|
(
|
||||||
|
$3 :: bool = true AND
|
||||||
|
$1 :: text LIKE replace(wildcard_hostname, '*', '%')
|
||||||
|
)
|
||||||
)
|
)
|
||||||
LIMIT
|
LIMIT
|
||||||
1
|
1
|
||||||
`
|
`
|
||||||
|
|
||||||
|
type GetWorkspaceProxyByHostnameParams struct {
|
||||||
|
Hostname string `db:"hostname" json:"hostname"`
|
||||||
|
AllowAccessUrl bool `db:"allow_access_url" json:"allow_access_url"`
|
||||||
|
AllowWildcardHostname bool `db:"allow_wildcard_hostname" json:"allow_wildcard_hostname"`
|
||||||
|
}
|
||||||
|
|
||||||
// Finds a workspace proxy that has an access URL or app hostname that matches
|
// 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
|
// the provided hostname. This is to check if a hostname matches any workspace
|
||||||
// proxy.
|
// proxy.
|
||||||
//
|
//
|
||||||
// The hostname must be sanitized to only contain [a-zA-Z0-9.-] before calling
|
// The hostname must be sanitized to only contain [a-zA-Z0-9.-] before calling
|
||||||
// this query. The scheme, port and path should be stripped.
|
// this query. The scheme, port and path should be stripped.
|
||||||
func (q *sqlQuerier) GetWorkspaceProxyByHostname(ctx context.Context, hostname string) (WorkspaceProxy, error) {
|
func (q *sqlQuerier) GetWorkspaceProxyByHostname(ctx context.Context, arg GetWorkspaceProxyByHostnameParams) (WorkspaceProxy, error) {
|
||||||
row := q.db.QueryRowContext(ctx, getWorkspaceProxyByHostname, hostname)
|
row := q.db.QueryRowContext(ctx, getWorkspaceProxyByHostname, arg.Hostname, arg.AllowAccessUrl, arg.AllowWildcardHostname)
|
||||||
var i WorkspaceProxy
|
var i WorkspaceProxy
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
|
|
|
@ -77,14 +77,20 @@ WHERE
|
||||||
--
|
--
|
||||||
-- Periods don't need to be escaped because they're not special characters
|
-- Periods don't need to be escaped because they're not special characters
|
||||||
-- in SQL matches unlike regular expressions.
|
-- in SQL matches unlike regular expressions.
|
||||||
@hostname :: text SIMILAR TO '[a-zA-Z0-9.-]+' AND
|
@hostname :: text SIMILAR TO '[a-zA-Z0-9._-]+' AND
|
||||||
deleted = false AND
|
deleted = false AND
|
||||||
|
|
||||||
-- Validate that the hostname matches either the wildcard hostname or the
|
-- Validate that the hostname matches either the wildcard hostname or the
|
||||||
-- access URL (ignoring scheme, port and path).
|
-- access URL (ignoring scheme, port and path).
|
||||||
(
|
(
|
||||||
url SIMILAR TO '[^:]*://' || @hostname :: text || '([:/]?%)*' OR
|
(
|
||||||
@hostname :: text LIKE replace(wildcard_hostname, '*', '%')
|
@allow_access_url :: bool = true AND
|
||||||
|
url SIMILAR TO '[^:]*://' || @hostname :: text || '([:/]?%)*'
|
||||||
|
) OR
|
||||||
|
(
|
||||||
|
@allow_wildcard_hostname :: bool = true AND
|
||||||
|
@hostname :: text LIKE replace(wildcard_hostname, '*', '%')
|
||||||
|
)
|
||||||
)
|
)
|
||||||
LIMIT
|
LIMIT
|
||||||
1;
|
1;
|
||||||
|
|
|
@ -1,15 +1,18 @@
|
||||||
package coderd
|
package coderd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/xerrors"
|
"golang.org/x/xerrors"
|
||||||
|
|
||||||
"github.com/coder/coder/coderd/database"
|
"github.com/coder/coder/coderd/database"
|
||||||
|
"github.com/coder/coder/coderd/database/dbauthz"
|
||||||
"github.com/coder/coder/coderd/httpapi"
|
"github.com/coder/coder/coderd/httpapi"
|
||||||
"github.com/coder/coder/coderd/httpmw"
|
"github.com/coder/coder/coderd/httpmw"
|
||||||
"github.com/coder/coder/coderd/rbac"
|
"github.com/coder/coder/coderd/rbac"
|
||||||
|
@ -73,45 +76,28 @@ func (api *API) workspaceApplicationAuth(rw http.ResponseWriter, r *http.Request
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Force the redirect URI to use the same scheme as the access URL for
|
|
||||||
// security purposes.
|
|
||||||
u.Scheme = api.AccessURL.Scheme
|
|
||||||
|
|
||||||
ok := false
|
u.Scheme, err = api.ValidWorkspaceAppHostname(ctx, u.Host, ValidWorkspaceAppHostnameOpts{
|
||||||
if api.AppHostnameRegex != nil {
|
// Allow all hosts except primary access URL since we don't need app
|
||||||
_, ok = httpapi.ExecuteHostnamePattern(api.AppHostnameRegex, u.Host)
|
// tokens on the primary dashboard URL.
|
||||||
|
AllowPrimaryAccessURL: false,
|
||||||
|
AllowPrimaryWildcard: true,
|
||||||
|
AllowProxyAccessURL: true,
|
||||||
|
AllowProxyWildcard: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||||
|
Message: "Failed to verify redirect_uri query parameter.",
|
||||||
|
Detail: err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
if u.Scheme == "" {
|
||||||
// Ensure that the redirect URI is a subdomain of api.Hostname and is a
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||||
// valid app subdomain.
|
Message: "Invalid redirect_uri.",
|
||||||
if !ok {
|
Detail: "The redirect_uri query parameter must be the primary wildcard app hostname, a workspace proxy access URL or a workspace proxy wildcard app hostname.",
|
||||||
proxy, err := api.Database.GetWorkspaceProxyByHostname(ctx, u.Hostname())
|
})
|
||||||
if xerrors.Is(err, sql.ErrNoRows) {
|
return
|
||||||
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
|
// Create the application_connect-scoped API key with the same lifetime as
|
||||||
|
@ -156,3 +142,66 @@ func (api *API) workspaceApplicationAuth(rw http.ResponseWriter, r *http.Request
|
||||||
u.RawQuery = q.Encode()
|
u.RawQuery = q.Encode()
|
||||||
http.Redirect(rw, r, u.String(), http.StatusSeeOther)
|
http.Redirect(rw, r, u.String(), http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ValidWorkspaceAppHostnameOpts struct {
|
||||||
|
AllowPrimaryAccessURL bool
|
||||||
|
AllowPrimaryWildcard bool
|
||||||
|
AllowProxyAccessURL bool
|
||||||
|
AllowProxyWildcard bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidWorkspaceAppHostname checks if the given host is a valid workspace app
|
||||||
|
// hostname based on the provided options. It returns a scheme to force on
|
||||||
|
// success. If the hostname is not valid or doesn't match, an empty string is
|
||||||
|
// returned. Any error returned is a 500 error.
|
||||||
|
//
|
||||||
|
// For hosts that match a wildcard app hostname, the scheme is forced to be the
|
||||||
|
// corresponding access URL scheme.
|
||||||
|
func (api *API) ValidWorkspaceAppHostname(ctx context.Context, host string, opts ValidWorkspaceAppHostnameOpts) (string, error) {
|
||||||
|
if opts.AllowPrimaryAccessURL && (host == api.AccessURL.Hostname() || host == api.AccessURL.Host) {
|
||||||
|
// Force the redirect URI to have the same scheme as the access URL for
|
||||||
|
// security purposes.
|
||||||
|
return api.AccessURL.Scheme, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.AllowPrimaryWildcard && api.AppHostnameRegex != nil {
|
||||||
|
_, ok := httpapi.ExecuteHostnamePattern(api.AppHostnameRegex, host)
|
||||||
|
if ok {
|
||||||
|
// Force the redirect URI to have the same scheme as the access URL
|
||||||
|
// for security purposes.
|
||||||
|
return api.AccessURL.Scheme, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure that the redirect URI is a subdomain of api.Hostname and is a
|
||||||
|
// valid app subdomain.
|
||||||
|
if opts.AllowProxyAccessURL || opts.AllowProxyWildcard {
|
||||||
|
// Strip the port for the database query.
|
||||||
|
host = strings.Split(host, ":")[0]
|
||||||
|
|
||||||
|
// nolint:gocritic // system query
|
||||||
|
systemCtx := dbauthz.AsSystemRestricted(ctx)
|
||||||
|
proxy, err := api.Database.GetWorkspaceProxyByHostname(systemCtx, database.GetWorkspaceProxyByHostnameParams{
|
||||||
|
Hostname: host,
|
||||||
|
AllowAccessUrl: opts.AllowProxyAccessURL,
|
||||||
|
AllowWildcardHostname: opts.AllowProxyWildcard,
|
||||||
|
})
|
||||||
|
if xerrors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", xerrors.Errorf("get workspace proxy by hostname %q: %w", host, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
proxyURL, err := url.Parse(proxy.Url)
|
||||||
|
if err != nil {
|
||||||
|
return "", xerrors.Errorf("parse proxy URL %q: %w", proxy.Url, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force the redirect URI to use the same scheme as the proxy access URL
|
||||||
|
// for security purposes.
|
||||||
|
return proxyURL.Scheme, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
|
@ -22,6 +22,7 @@ import (
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"golang.org/x/xerrors"
|
"golang.org/x/xerrors"
|
||||||
|
"nhooyr.io/websocket"
|
||||||
|
|
||||||
"github.com/coder/coder/coderd/coderdtest"
|
"github.com/coder/coder/coderd/coderdtest"
|
||||||
"github.com/coder/coder/coderd/rbac"
|
"github.com/coder/coder/coderd/rbac"
|
||||||
|
@ -46,43 +47,9 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||||
t.Skip("ConPTY appears to be inconsistent on Windows.")
|
t.Skip("ConPTY appears to be inconsistent on Windows.")
|
||||||
}
|
}
|
||||||
|
|
||||||
appDetails := setupProxyTest(t, nil)
|
expectLine := func(r *bufio.Reader, matcher func(string) bool) {
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// Run the test against the path app hostname since that's where the
|
|
||||||
// reconnecting-pty proxy server we want to test is mounted.
|
|
||||||
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()
|
|
||||||
|
|
||||||
// First attempt to resize the TTY.
|
|
||||||
// The websocket will close if it fails!
|
|
||||||
data, err := json.Marshal(codersdk.ReconnectingPTYRequest{
|
|
||||||
Height: 250,
|
|
||||||
Width: 250,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
_, err = conn.Write(data)
|
|
||||||
require.NoError(t, err)
|
|
||||||
bufRead := bufio.NewReader(conn)
|
|
||||||
|
|
||||||
// Brief pause to reduce the likelihood that we send keystrokes while
|
|
||||||
// the shell is simultaneously sending a prompt.
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
|
||||||
|
|
||||||
data, err = json.Marshal(codersdk.ReconnectingPTYRequest{
|
|
||||||
Data: "echo test\r\n",
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
_, err = conn.Write(data)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
expectLine := func(matcher func(string) bool) {
|
|
||||||
for {
|
for {
|
||||||
line, err := bufRead.ReadString('\n')
|
line, err := r.ReadString('\n')
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
if matcher(line) {
|
if matcher(line) {
|
||||||
break
|
break
|
||||||
|
@ -96,8 +63,93 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||||
return strings.Contains(line, "test") && !strings.Contains(line, "echo")
|
return strings.Contains(line, "test") && !strings.Contains(line, "echo")
|
||||||
}
|
}
|
||||||
|
|
||||||
expectLine(matchEchoCommand)
|
t.Run("OK", func(t *testing.T) {
|
||||||
expectLine(matchEchoOutput)
|
appDetails := setupProxyTest(t, nil)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Run the test against the path app hostname since that's where the
|
||||||
|
// reconnecting-pty proxy server we want to test is mounted.
|
||||||
|
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()
|
||||||
|
|
||||||
|
// First attempt to resize the TTY.
|
||||||
|
// The websocket will close if it fails!
|
||||||
|
data, err := json.Marshal(codersdk.ReconnectingPTYRequest{
|
||||||
|
Height: 250,
|
||||||
|
Width: 250,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = conn.Write(data)
|
||||||
|
require.NoError(t, err)
|
||||||
|
bufRead := bufio.NewReader(conn)
|
||||||
|
|
||||||
|
// Brief pause to reduce the likelihood that we send keystrokes while
|
||||||
|
// the shell is simultaneously sending a prompt.
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
data, err = json.Marshal(codersdk.ReconnectingPTYRequest{
|
||||||
|
Data: "echo test\r\n",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = conn.Write(data)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
expectLine(bufRead, matchEchoCommand)
|
||||||
|
expectLine(bufRead, matchEchoOutput)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("SignedTokenQueryParameter", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
appDetails := setupProxyTest(t, nil)
|
||||||
|
if appDetails.AppHostIsPrimary {
|
||||||
|
t.Skip("Tickets are not used for terminal requests on the primary.")
|
||||||
|
}
|
||||||
|
|
||||||
|
u := *appDetails.PathAppBaseURL
|
||||||
|
if u.Scheme == "http" {
|
||||||
|
u.Scheme = "ws"
|
||||||
|
} else {
|
||||||
|
u.Scheme = "wss"
|
||||||
|
}
|
||||||
|
u.Path = fmt.Sprintf("/api/v2/workspaceagents/%s/pty", appDetails.Agent.ID.String())
|
||||||
|
|
||||||
|
ctx := testutil.Context(t, testutil.WaitLong)
|
||||||
|
issueRes, err := appDetails.SDKClient.IssueReconnectingPTYSignedToken(ctx, codersdk.IssueReconnectingPTYSignedTokenRequest{
|
||||||
|
URL: u.String(),
|
||||||
|
AgentID: appDetails.Agent.ID,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Try to connect to the endpoint with the signed token and no other
|
||||||
|
// authentication.
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("reconnect", uuid.NewString())
|
||||||
|
q.Set("height", strconv.Itoa(24))
|
||||||
|
q.Set("width", strconv.Itoa(80))
|
||||||
|
q.Set("command", `/bin/sh -c "echo test"`)
|
||||||
|
q.Set(codersdk.SignedAppTokenQueryParameter, issueRes.SignedToken)
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
//nolint:bodyclose
|
||||||
|
wsConn, res, err := websocket.Dial(ctx, u.String(), nil)
|
||||||
|
if !assert.NoError(t, err) {
|
||||||
|
dump, err := httputil.DumpResponse(res, true)
|
||||||
|
if err == nil {
|
||||||
|
t.Log(string(dump))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer wsConn.Close(websocket.StatusNormalClosure, "")
|
||||||
|
conn := websocket.NetConn(ctx, wsConn, websocket.MessageBinary)
|
||||||
|
bufRead := bufio.NewReader(conn)
|
||||||
|
|
||||||
|
expectLine(bufRead, matchEchoOutput)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("WorkspaceAppsProxyPath", func(t *testing.T) {
|
t.Run("WorkspaceAppsProxyPath", func(t *testing.T) {
|
||||||
|
|
|
@ -594,7 +594,7 @@ func (s *Server) workspaceAgentPTY(rw http.ResponseWriter, r *http.Request) {
|
||||||
BasePath: r.URL.Path,
|
BasePath: r.URL.Path,
|
||||||
AgentNameOrID: chi.URLParam(r, "workspaceagent"),
|
AgentNameOrID: chi.URLParam(r, "workspaceagent"),
|
||||||
},
|
},
|
||||||
AppPath: r.URL.Path,
|
AppPath: "",
|
||||||
AppQuery: "",
|
AppQuery: "",
|
||||||
})
|
})
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
|
@ -1,8 +1,3 @@
|
||||||
package workspaceapps_test
|
package workspaceapps_test
|
||||||
|
|
||||||
// NOTE: for now, app proxying tests are still in their old locations, pending
|
// App tests can be found in the apptest package.
|
||||||
// being moved to their own package.
|
|
||||||
//
|
|
||||||
// See:
|
|
||||||
// - coderd/workspaceapps_test.go
|
|
||||||
// - coderd/workspaceagents_test.go (for PTY)
|
|
||||||
|
|
|
@ -224,13 +224,30 @@ func (k SecurityKey) DecryptAPIKey(encryptedAPIKey string) (string, error) {
|
||||||
return payload.APIKey, nil
|
return payload.APIKey, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FromRequest returns the signed token from the request, if it exists and is
|
||||||
|
// valid. The caller must check that the token matches the request.
|
||||||
func FromRequest(r *http.Request, key SecurityKey) (*SignedToken, bool) {
|
func FromRequest(r *http.Request, key SecurityKey) (*SignedToken, bool) {
|
||||||
// Get the existing token from the request.
|
// Get the token string from the request. We usually use a cookie for this,
|
||||||
tokenCookie, err := r.Cookie(codersdk.DevURLSignedAppTokenCookie)
|
// but for web terminal we also support a query parameter to support
|
||||||
if err == nil {
|
// cross-domain terminal access.
|
||||||
token, err := key.VerifySignedToken(tokenCookie.Value)
|
tokenStr := ""
|
||||||
|
tokenCookie, cookieErr := r.Cookie(codersdk.DevURLSignedAppTokenCookie)
|
||||||
|
if cookieErr == nil {
|
||||||
|
tokenStr = tokenCookie.Value
|
||||||
|
} else {
|
||||||
|
tokenStr = r.URL.Query().Get(codersdk.SignedAppTokenQueryParameter)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tokenStr != "" {
|
||||||
|
token, err := key.VerifySignedToken(tokenStr)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
req := token.Request.Normalize()
|
req := token.Request.Normalize()
|
||||||
|
if cookieErr != nil && req.AccessMethod != AccessMethodTerminal {
|
||||||
|
// The request must be a terminal request if we're using a
|
||||||
|
// query parameter.
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
err := req.Validate()
|
err := req.Validate()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// The request has a valid signed app token, which is a valid
|
// The request has a valid signed app token, which is a valid
|
||||||
|
|
|
@ -47,6 +47,15 @@ const (
|
||||||
// token.
|
// token.
|
||||||
//nolint:gosec
|
//nolint:gosec
|
||||||
DevURLSignedAppTokenCookie = "coder_devurl_signed_app_token"
|
DevURLSignedAppTokenCookie = "coder_devurl_signed_app_token"
|
||||||
|
// SignedAppTokenQueryParameter is the name of the query parameter that
|
||||||
|
// stores a temporary JWT that can be used to authenticate instead of the
|
||||||
|
// session token. This is only acceptable on reconnecting-pty requests, not
|
||||||
|
// apps.
|
||||||
|
//
|
||||||
|
// It has a random suffix to avoid conflict with user query parameters on
|
||||||
|
// apps.
|
||||||
|
//nolint:gosec
|
||||||
|
SignedAppTokenQueryParameter = "coder_signed_app_token_23db1dde"
|
||||||
|
|
||||||
// BypassRatelimitHeader is the custom header to use to bypass ratelimits.
|
// BypassRatelimitHeader is the custom header to use to bypass ratelimits.
|
||||||
// Only owners can bypass rate limits. This is typically used for scale testing.
|
// Only owners can bypass rate limits. This is typically used for scale testing.
|
||||||
|
@ -289,8 +298,8 @@ func ReadBodyAsError(res *http.Response) error {
|
||||||
|
|
||||||
mimeType := parseMimeType(contentType)
|
mimeType := parseMimeType(contentType)
|
||||||
if mimeType != "application/json" {
|
if mimeType != "application/json" {
|
||||||
if len(resp) > 1024 {
|
if len(resp) > 2048 {
|
||||||
resp = append(resp[:1024], []byte("...")...)
|
resp = append(resp[:2048], []byte("...")...)
|
||||||
}
|
}
|
||||||
if len(resp) == 0 {
|
if len(resp) == 0 {
|
||||||
resp = []byte("no response body")
|
resp = []byte("no response body")
|
||||||
|
|
|
@ -174,7 +174,7 @@ func Test_readBodyAsError(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
longResponse := ""
|
longResponse := ""
|
||||||
for i := 0; i < 2000; i++ {
|
for i := 0; i < 4000; i++ {
|
||||||
longResponse += "a"
|
longResponse += "a"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -258,7 +258,7 @@ func Test_readBodyAsError(t *testing.T) {
|
||||||
|
|
||||||
assert.Contains(t, sdkErr.Response.Message, "unexpected non-JSON response")
|
assert.Contains(t, sdkErr.Response.Message, "unexpected non-JSON response")
|
||||||
|
|
||||||
expected := longResponse[0:1024] + "..."
|
expected := longResponse[0:2048] + "..."
|
||||||
assert.Equal(t, expected, sdkErr.Response.Detail)
|
assert.Equal(t, expected, sdkErr.Response.Detail)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -362,6 +362,29 @@ func (c *Client) WorkspaceAgent(ctx context.Context, id uuid.UUID) (WorkspaceAge
|
||||||
return workspaceAgent, json.NewDecoder(res.Body).Decode(&workspaceAgent)
|
return workspaceAgent, json.NewDecoder(res.Body).Decode(&workspaceAgent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type IssueReconnectingPTYSignedTokenRequest struct {
|
||||||
|
// URL is the URL of the reconnecting-pty endpoint you are connecting to.
|
||||||
|
URL string `json:"url" validate:"required"`
|
||||||
|
AgentID uuid.UUID `json:"agentID" format:"uuid" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type IssueReconnectingPTYSignedTokenResponse struct {
|
||||||
|
SignedToken string `json:"signed_token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) IssueReconnectingPTYSignedToken(ctx context.Context, req IssueReconnectingPTYSignedTokenRequest) (IssueReconnectingPTYSignedTokenResponse, error) {
|
||||||
|
res, err := c.Request(ctx, http.MethodPost, "/api/v2/applications/reconnecting-pty-signed-token", req)
|
||||||
|
if err != nil {
|
||||||
|
return IssueReconnectingPTYSignedTokenResponse{}, err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
if res.StatusCode != http.StatusOK {
|
||||||
|
return IssueReconnectingPTYSignedTokenResponse{}, ReadBodyAsError(res)
|
||||||
|
}
|
||||||
|
var resp IssueReconnectingPTYSignedTokenResponse
|
||||||
|
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
||||||
|
}
|
||||||
|
|
||||||
// WorkspaceAgentReconnectingPTY spawns a PTY that reconnects using the token provided.
|
// WorkspaceAgentReconnectingPTY spawns a PTY that reconnects using the token provided.
|
||||||
// It communicates using `agent.ReconnectingPTYRequest` marshaled as JSON.
|
// It communicates using `agent.ReconnectingPTYRequest` marshaled as JSON.
|
||||||
// Responses are PTY output that can be rendered.
|
// Responses are PTY output that can be rendered.
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
# Applications Enterprise
|
|
@ -2655,6 +2655,36 @@ CreateParameterRequest is a structure used to create a new parameter value for a
|
||||||
| `threshold` | integer | false | | Threshold specifies the number of consecutive failed health checks before returning "unhealthy". |
|
| `threshold` | integer | false | | Threshold specifies the number of consecutive failed health checks before returning "unhealthy". |
|
||||||
| `url` | string | false | | URL specifies the endpoint to check for the app health. |
|
| `url` | string | false | | URL specifies the endpoint to check for the app health. |
|
||||||
|
|
||||||
|
## codersdk.IssueReconnectingPTYSignedTokenRequest
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agentID": "bc282582-04f9-45ce-b904-3e3bfab66958",
|
||||||
|
"url": "string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Properties
|
||||||
|
|
||||||
|
| Name | Type | Required | Restrictions | Description |
|
||||||
|
| --------- | ------ | -------- | ------------ | ---------------------------------------------------------------------- |
|
||||||
|
| `agentID` | string | true | | |
|
||||||
|
| `url` | string | true | | URL is the URL of the reconnecting-pty endpoint you are connecting to. |
|
||||||
|
|
||||||
|
## codersdk.IssueReconnectingPTYSignedTokenResponse
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"signed_token": "string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Properties
|
||||||
|
|
||||||
|
| Name | Type | Required | Restrictions | Description |
|
||||||
|
| -------------- | ------ | -------- | ------------ | ----------- |
|
||||||
|
| `signed_token` | string | false | | |
|
||||||
|
|
||||||
## codersdk.JobErrorCode
|
## codersdk.JobErrorCode
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|
|
@ -406,6 +406,10 @@
|
||||||
"title": "Applications",
|
"title": "Applications",
|
||||||
"path": "./api/applications.md"
|
"path": "./api/applications.md"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"title": "Applications Enterprise",
|
||||||
|
"path": "./api/applications enterprise.md"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"title": "Audit",
|
"title": "Audit",
|
||||||
"path": "./api/audit.md"
|
"path": "./api/audit.md"
|
||||||
|
|
|
@ -81,6 +81,10 @@ func New(ctx context.Context, options *Options) (*API, error) {
|
||||||
r.Get("/", api.licenses)
|
r.Get("/", api.licenses)
|
||||||
r.Delete("/{id}", api.deleteLicense)
|
r.Delete("/{id}", api.deleteLicense)
|
||||||
})
|
})
|
||||||
|
r.Route("/applications/reconnecting-pty-signed-token", func(r chi.Router) {
|
||||||
|
r.Use(apiKeyMiddleware)
|
||||||
|
r.Post("/", api.reconnectingPTYSignedToken)
|
||||||
|
})
|
||||||
r.Route("/workspaceproxies", func(r chi.Router) {
|
r.Route("/workspaceproxies", func(r chi.Router) {
|
||||||
r.Use(
|
r.Use(
|
||||||
api.moonsEnabledMW,
|
api.moonsEnabledMW,
|
||||||
|
|
|
@ -10,10 +10,12 @@ import (
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"golang.org/x/xerrors"
|
"golang.org/x/xerrors"
|
||||||
|
|
||||||
|
agpl "github.com/coder/coder/coderd"
|
||||||
"github.com/coder/coder/coderd/audit"
|
"github.com/coder/coder/coderd/audit"
|
||||||
"github.com/coder/coder/coderd/database"
|
"github.com/coder/coder/coderd/database"
|
||||||
"github.com/coder/coder/coderd/httpapi"
|
"github.com/coder/coder/coderd/httpapi"
|
||||||
"github.com/coder/coder/coderd/httpmw"
|
"github.com/coder/coder/coderd/httpmw"
|
||||||
|
"github.com/coder/coder/coderd/rbac"
|
||||||
"github.com/coder/coder/coderd/workspaceapps"
|
"github.com/coder/coder/coderd/workspaceapps"
|
||||||
"github.com/coder/coder/codersdk"
|
"github.com/coder/coder/codersdk"
|
||||||
"github.com/coder/coder/cryptorand"
|
"github.com/coder/coder/cryptorand"
|
||||||
|
@ -314,3 +316,99 @@ func (api *API) workspaceProxyRegister(rw http.ResponseWriter, r *http.Request)
|
||||||
AppSecurityKey: api.AppSecurityKey.String(),
|
AppSecurityKey: api.AppSecurityKey.String(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reconnectingPTYSignedToken issues a signed app token for use when connecting
|
||||||
|
// to the reconnecting PTY websocket on an external workspace proxy. This is set
|
||||||
|
// by the client as a query parameter when connecting.
|
||||||
|
//
|
||||||
|
// @Summary Issue signed app token for reconnecting PTY
|
||||||
|
// @ID issue-signed-app-token-for-reconnecting-pty
|
||||||
|
// @Security CoderSessionToken
|
||||||
|
// @Tags Applications Enterprise
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body codersdk.IssueReconnectingPTYSignedTokenRequest true "Issue reconnecting PTY signed token request"
|
||||||
|
// @Success 200 {object} codersdk.IssueReconnectingPTYSignedTokenResponse
|
||||||
|
// @Router /applications/reconnecting-pty-signed-token [post]
|
||||||
|
// @x-apidocgen {"skip": true}
|
||||||
|
func (api *API) reconnectingPTYSignedToken(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
apiKey := httpmw.APIKey(r)
|
||||||
|
if !api.Authorize(r, rbac.ActionCreate, apiKey) {
|
||||||
|
httpapi.ResourceNotFound(rw)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req codersdk.IssueReconnectingPTYSignedTokenRequest
|
||||||
|
if !httpapi.Read(ctx, rw, r, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := url.Parse(req.URL)
|
||||||
|
if err == nil && u.Scheme != "ws" && u.Scheme != "wss" {
|
||||||
|
err = xerrors.Errorf("invalid URL scheme %q, expected 'ws' or 'wss'", u.Scheme)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||||
|
Message: "Invalid URL.",
|
||||||
|
Detail: err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert the URL is a valid reconnecting-pty URL.
|
||||||
|
expectedPath := fmt.Sprintf("/api/v2/workspaceagents/%s/pty", req.AgentID.String())
|
||||||
|
if u.Path != expectedPath {
|
||||||
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||||
|
Message: "Invalid URL path.",
|
||||||
|
Detail: "The provided URL is not a valid reconnecting PTY endpoint URL.",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scheme, err := api.AGPL.ValidWorkspaceAppHostname(ctx, u.Host, agpl.ValidWorkspaceAppHostnameOpts{
|
||||||
|
// Only allow the proxy access URL as a hostname since we don't need a
|
||||||
|
// ticket for the primary dashboard URL terminal.
|
||||||
|
AllowPrimaryAccessURL: false,
|
||||||
|
AllowPrimaryWildcard: false,
|
||||||
|
AllowProxyAccessURL: true,
|
||||||
|
AllowProxyWildcard: false,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||||
|
Message: "Failed to verify hostname in URL.",
|
||||||
|
Detail: err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if scheme == "" {
|
||||||
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||||
|
Message: "Invalid hostname in URL.",
|
||||||
|
Detail: "The hostname must be the primary wildcard app hostname, a workspace proxy access URL or a workspace proxy wildcard app hostname.",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, tokenStr, ok := api.AGPL.WorkspaceAppsProvider.Issue(ctx, rw, r, workspaceapps.IssueTokenRequest{
|
||||||
|
AppRequest: workspaceapps.Request{
|
||||||
|
AccessMethod: workspaceapps.AccessMethodTerminal,
|
||||||
|
BasePath: u.Path,
|
||||||
|
AgentNameOrID: req.AgentID.String(),
|
||||||
|
},
|
||||||
|
SessionToken: httpmw.APITokenFromRequest(r),
|
||||||
|
// The following fields aren't required as long as the request is authed
|
||||||
|
// with a valid API key.
|
||||||
|
PathAppBaseURL: "",
|
||||||
|
AppHostname: "",
|
||||||
|
// The following fields are empty for terminal apps.
|
||||||
|
AppPath: "",
|
||||||
|
AppQuery: "",
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
httpapi.Write(ctx, rw, http.StatusOK, codersdk.IssueReconnectingPTYSignedTokenResponse{
|
||||||
|
SignedToken: tokenStr,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
@ -1,8 +1,11 @@
|
||||||
package coderd_test
|
package coderd_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
|
"net/url"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
@ -206,3 +209,213 @@ func TestIssueSignedAppToken(t *testing.T) {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReconnectingPTYSignedToken(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dv := coderdtest.DeploymentValues(t)
|
||||||
|
dv.Experiments = []string{
|
||||||
|
string(codersdk.ExperimentMoons),
|
||||||
|
"*",
|
||||||
|
}
|
||||||
|
|
||||||
|
db, pubsub := dbtestutil.NewDB(t)
|
||||||
|
client := coderdenttest.New(t, &coderdenttest.Options{
|
||||||
|
Options: &coderdtest.Options{
|
||||||
|
DeploymentValues: dv,
|
||||||
|
Database: db,
|
||||||
|
Pubsub: pubsub,
|
||||||
|
IncludeProvisionerDaemon: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
user := coderdtest.CreateFirstUser(t, client)
|
||||||
|
_ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
|
||||||
|
Features: license.Features{
|
||||||
|
codersdk.FeatureWorkspaceProxy: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create a workspace + apps
|
||||||
|
authToken := uuid.NewString()
|
||||||
|
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||||
|
Parse: echo.ParseComplete,
|
||||||
|
ProvisionApply: echo.ProvisionApplyWithAgent(authToken),
|
||||||
|
})
|
||||||
|
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||||
|
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||||
|
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||||
|
build := coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||||
|
workspace.LatestBuild = build
|
||||||
|
|
||||||
|
// Connect an agent to the workspace
|
||||||
|
agentID := build.Resources[0].Agents[0].ID
|
||||||
|
agentClient := agentsdk.New(client.URL)
|
||||||
|
agentClient.SetSessionToken(authToken)
|
||||||
|
agentCloser := agent.New(agent.Options{
|
||||||
|
Client: agentClient,
|
||||||
|
Logger: slogtest.Make(t, nil).Named("agent").Leveled(slog.LevelDebug),
|
||||||
|
})
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = agentCloser.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
||||||
|
|
||||||
|
createProxyCtx := testutil.Context(t, testutil.WaitLong)
|
||||||
|
proxyRes, err := client.CreateWorkspaceProxy(createProxyCtx, codersdk.CreateWorkspaceProxyRequest{
|
||||||
|
Name: namesgenerator.GetRandomName(1),
|
||||||
|
Icon: "/emojis/flag.png",
|
||||||
|
URL: "https://" + namesgenerator.GetRandomName(1) + ".com",
|
||||||
|
WildcardHostname: "*.sub.example.com",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
u, err := url.Parse(proxyRes.Proxy.URL)
|
||||||
|
require.NoError(t, err)
|
||||||
|
if u.Scheme == "https" {
|
||||||
|
u.Scheme = "wss"
|
||||||
|
} else {
|
||||||
|
u.Scheme = "ws"
|
||||||
|
}
|
||||||
|
u.Path = fmt.Sprintf("/api/v2/workspaceagents/%s/pty", agentID.String())
|
||||||
|
|
||||||
|
t.Run("Validate", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := testutil.Context(t, testutil.WaitLong)
|
||||||
|
res, err := client.IssueReconnectingPTYSignedToken(ctx, codersdk.IssueReconnectingPTYSignedTokenRequest{
|
||||||
|
URL: "",
|
||||||
|
AgentID: uuid.Nil,
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Empty(t, res)
|
||||||
|
var sdkErr *codersdk.Error
|
||||||
|
require.ErrorAs(t, err, &sdkErr)
|
||||||
|
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("BadURL", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := testutil.Context(t, testutil.WaitLong)
|
||||||
|
res, err := client.IssueReconnectingPTYSignedToken(ctx, codersdk.IssueReconnectingPTYSignedTokenRequest{
|
||||||
|
URL: ":",
|
||||||
|
AgentID: agentID,
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Empty(t, res)
|
||||||
|
var sdkErr *codersdk.Error
|
||||||
|
require.ErrorAs(t, err, &sdkErr)
|
||||||
|
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
||||||
|
require.Contains(t, sdkErr.Response.Message, "Invalid URL")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("BadURL", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
u := *u
|
||||||
|
u.Scheme = "ftp"
|
||||||
|
|
||||||
|
ctx := testutil.Context(t, testutil.WaitLong)
|
||||||
|
res, err := client.IssueReconnectingPTYSignedToken(ctx, codersdk.IssueReconnectingPTYSignedTokenRequest{
|
||||||
|
URL: u.String(),
|
||||||
|
AgentID: agentID,
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Empty(t, res)
|
||||||
|
var sdkErr *codersdk.Error
|
||||||
|
require.ErrorAs(t, err, &sdkErr)
|
||||||
|
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
||||||
|
require.Contains(t, sdkErr.Response.Message, "Invalid URL")
|
||||||
|
require.Contains(t, sdkErr.Response.Detail, "scheme")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("BadURLPath", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
u := *u
|
||||||
|
u.Path = "/hello"
|
||||||
|
|
||||||
|
ctx := testutil.Context(t, testutil.WaitLong)
|
||||||
|
res, err := client.IssueReconnectingPTYSignedToken(ctx, codersdk.IssueReconnectingPTYSignedTokenRequest{
|
||||||
|
URL: u.String(),
|
||||||
|
AgentID: agentID,
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Empty(t, res)
|
||||||
|
var sdkErr *codersdk.Error
|
||||||
|
require.ErrorAs(t, err, &sdkErr)
|
||||||
|
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
||||||
|
require.Contains(t, sdkErr.Response.Message, "Invalid URL")
|
||||||
|
require.Contains(t, sdkErr.Response.Detail, "The provided URL is not a valid reconnecting PTY endpoint URL")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("BadHostname", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
u := *u
|
||||||
|
u.Host = "badhostname.com"
|
||||||
|
|
||||||
|
ctx := testutil.Context(t, testutil.WaitLong)
|
||||||
|
res, err := client.IssueReconnectingPTYSignedToken(ctx, codersdk.IssueReconnectingPTYSignedTokenRequest{
|
||||||
|
URL: u.String(),
|
||||||
|
AgentID: agentID,
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Empty(t, res)
|
||||||
|
var sdkErr *codersdk.Error
|
||||||
|
require.ErrorAs(t, err, &sdkErr)
|
||||||
|
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
||||||
|
require.Contains(t, sdkErr.Response.Message, "Invalid hostname in URL")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("NoToken", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
unauthedClient := codersdk.New(client.URL)
|
||||||
|
|
||||||
|
ctx := testutil.Context(t, testutil.WaitLong)
|
||||||
|
res, err := unauthedClient.IssueReconnectingPTYSignedToken(ctx, codersdk.IssueReconnectingPTYSignedTokenRequest{
|
||||||
|
URL: u.String(),
|
||||||
|
AgentID: agentID,
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Empty(t, res)
|
||||||
|
var sdkErr *codersdk.Error
|
||||||
|
require.ErrorAs(t, err, &sdkErr)
|
||||||
|
require.Equal(t, http.StatusUnauthorized, sdkErr.StatusCode())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("NoPermissions", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
userClient, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
|
||||||
|
|
||||||
|
ctx := testutil.Context(t, testutil.WaitLong)
|
||||||
|
res, err := userClient.IssueReconnectingPTYSignedToken(ctx, codersdk.IssueReconnectingPTYSignedTokenRequest{
|
||||||
|
URL: u.String(),
|
||||||
|
AgentID: agentID,
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Empty(t, res)
|
||||||
|
var sdkErr *codersdk.Error
|
||||||
|
require.ErrorAs(t, err, &sdkErr)
|
||||||
|
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("OK", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := testutil.Context(t, testutil.WaitLong)
|
||||||
|
res, err := client.IssueReconnectingPTYSignedToken(ctx, codersdk.IssueReconnectingPTYSignedTokenRequest{
|
||||||
|
URL: u.String(),
|
||||||
|
AgentID: agentID,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, res.SignedToken)
|
||||||
|
|
||||||
|
// The token is validated in the apptest suite, so we don't need to
|
||||||
|
// validate it here.
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
@ -466,6 +466,17 @@ export interface Healthcheck {
|
||||||
readonly threshold: number
|
readonly threshold: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// From codersdk/workspaceagents.go
|
||||||
|
export interface IssueReconnectingPTYSignedTokenRequest {
|
||||||
|
readonly url: string
|
||||||
|
readonly agentID: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// From codersdk/workspaceagents.go
|
||||||
|
export interface IssueReconnectingPTYSignedTokenResponse {
|
||||||
|
readonly signed_token: string
|
||||||
|
}
|
||||||
|
|
||||||
// From codersdk/licenses.go
|
// From codersdk/licenses.go
|
||||||
export interface License {
|
export interface License {
|
||||||
readonly id: number
|
readonly id: number
|
||||||
|
|
Loading…
Reference in New Issue