chore: Add workspace proxy enterprise cli commands (#7176)

* feat: Add workspace proxy enterprise cli commands
* chore: Handle custom workspace proxy options. Remove excess
* chore: Add endpoint to register workspace proxies
This commit is contained in:
Steven Masley 2023-04-20 09:48:47 -05:00 committed by GitHub
parent 8926c10b7d
commit a5a5c4d400
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 1558 additions and 107 deletions

View File

@ -80,6 +80,17 @@ func (s *OptionSet) Add(opts ...Option) {
*s = append(*s, opts...)
}
// Filter will only return options that match the given filter. (return true)
func (s OptionSet) Filter(filter func(opt Option) bool) OptionSet {
cpy := make(OptionSet, 0)
for _, opt := range s {
if filter(opt) {
cpy = append(cpy, opt)
}
}
return cpy
}
// FlagSet returns a pflag.FlagSet for the OptionSet.
func (s *OptionSet) FlagSet() *pflag.FlagSet {
if s == nil {

View File

@ -192,3 +192,35 @@ func (textFormat) AttachOptions(_ *clibase.OptionSet) {}
func (textFormat) Format(_ context.Context, data any) (string, error) {
return fmt.Sprintf("%s", data), nil
}
// DataChangeFormat allows manipulating the data passed to an output format.
// This is because sometimes the data needs to be manipulated before it can be
// passed to the output format.
// For example, you may want to pass something different to the text formatter
// than what you pass to the json formatter.
type DataChangeFormat struct {
format OutputFormat
change func(data any) (any, error)
}
// ChangeFormatterData allows manipulating the data passed to an output
// format.
func ChangeFormatterData(format OutputFormat, change func(data any) (any, error)) *DataChangeFormat {
return &DataChangeFormat{format: format, change: change}
}
func (d *DataChangeFormat) ID() string {
return d.format.ID()
}
func (d *DataChangeFormat) AttachOptions(opts *clibase.OptionSet) {
d.format.AttachOptions(opts)
}
func (d *DataChangeFormat) Format(ctx context.Context, data any) (string, error) {
newData, err := d.change(data)
if err != nil {
return "", err
}
return d.format.Format(ctx, newData)
}

98
coderd/apidoc/docs.go generated
View File

@ -5067,6 +5067,83 @@ const docTemplate = `{
}
}
},
"/workspaceproxies/me/register": {
"post": {
"security": [
{
"CoderSessionToken": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Enterprise"
],
"summary": "Register workspace proxy",
"operationId": "register-workspace-proxy",
"parameters": [
{
"description": "Issue signed app token request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/wsproxysdk.RegisterWorkspaceProxyRequest"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/wsproxysdk.RegisterWorkspaceProxyResponse"
}
}
},
"x-apidocgen": {
"skip": true
}
}
},
"/workspaceproxies/{workspaceproxy}": {
"delete": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": [
"application/json"
],
"tags": [
"Enterprise"
],
"summary": "Delete workspace proxy",
"operationId": "delete-workspace-proxy",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Proxy ID or name",
"name": "workspaceproxy",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.Response"
}
}
}
}
},
"/workspaces": {
"get": {
"security": [
@ -10213,6 +10290,27 @@ const docTemplate = `{
"type": "string"
}
}
},
"wsproxysdk.RegisterWorkspaceProxyRequest": {
"type": "object",
"properties": {
"access_url": {
"description": "AccessURL that hits the workspace proxy api.",
"type": "string"
},
"wildcard_hostname": {
"description": "WildcardHostname that the workspace proxy api is serving for subdomain apps.",
"type": "string"
}
}
},
"wsproxysdk.RegisterWorkspaceProxyResponse": {
"type": "object",
"properties": {
"app_security_key": {
"type": "string"
}
}
}
},
"securityDefinitions": {

View File

@ -4459,6 +4459,73 @@
}
}
},
"/workspaceproxies/me/register": {
"post": {
"security": [
{
"CoderSessionToken": []
}
],
"consumes": ["application/json"],
"produces": ["application/json"],
"tags": ["Enterprise"],
"summary": "Register workspace proxy",
"operationId": "register-workspace-proxy",
"parameters": [
{
"description": "Issue signed app token request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/wsproxysdk.RegisterWorkspaceProxyRequest"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/wsproxysdk.RegisterWorkspaceProxyResponse"
}
}
},
"x-apidocgen": {
"skip": true
}
}
},
"/workspaceproxies/{workspaceproxy}": {
"delete": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": ["application/json"],
"tags": ["Enterprise"],
"summary": "Delete workspace proxy",
"operationId": "delete-workspace-proxy",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Proxy ID or name",
"name": "workspaceproxy",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.Response"
}
}
}
}
},
"/workspaces": {
"get": {
"security": [
@ -9272,6 +9339,27 @@
"type": "string"
}
}
},
"wsproxysdk.RegisterWorkspaceProxyRequest": {
"type": "object",
"properties": {
"access_url": {
"description": "AccessURL that hits the workspace proxy api.",
"type": "string"
},
"wildcard_hostname": {
"description": "WildcardHostname that the workspace proxy api is serving for subdomain apps.",
"type": "string"
}
}
},
"wsproxysdk.RegisterWorkspaceProxyResponse": {
"type": "object",
"properties": {
"app_security_key": {
"type": "string"
}
}
}
},
"securityDefinitions": {

View File

@ -181,6 +181,7 @@ var (
rbac.ResourceUserData.Type: {rbac.ActionCreate, rbac.ActionUpdate},
rbac.ResourceWorkspace.Type: {rbac.ActionUpdate},
rbac.ResourceWorkspaceExecution.Type: {rbac.ActionCreate},
rbac.ResourceWorkspaceProxy.Type: {rbac.ActionCreate, rbac.ActionUpdate, rbac.ActionDelete},
}),
Org: map[string][]rbac.Permission{},
User: []rbac.Permission{},

View File

@ -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) GetWorkspaceProxyByName(ctx context.Context, name string) (database.WorkspaceProxy, error) {
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)
}
@ -1705,11 +1709,11 @@ func (q *querier) InsertWorkspaceProxy(ctx context.Context, arg database.InsertW
return insert(q.log, q.auth, rbac.ResourceWorkspaceProxy, q.db.InsertWorkspaceProxy)(ctx, arg)
}
func (q *querier) UpdateWorkspaceProxy(ctx context.Context, arg database.UpdateWorkspaceProxyParams) (database.WorkspaceProxy, error) {
fetch := func(ctx context.Context, arg database.UpdateWorkspaceProxyParams) (database.WorkspaceProxy, error) {
func (q *querier) RegisterWorkspaceProxy(ctx context.Context, arg database.RegisterWorkspaceProxyParams) (database.WorkspaceProxy, error) {
fetch := func(ctx context.Context, arg database.RegisterWorkspaceProxyParams) (database.WorkspaceProxy, error) {
return q.db.GetWorkspaceProxyByID(ctx, arg.ID)
}
return updateWithReturn(q.log, q.auth, fetch, q.db.UpdateWorkspaceProxy)(ctx, arg)
return updateWithReturn(q.log, q.auth, fetch, q.db.RegisterWorkspaceProxy)(ctx, arg)
}
func (q *querier) UpdateWorkspaceProxyDeleted(ctx context.Context, arg database.UpdateWorkspaceProxyDeletedParams) error {

View File

@ -444,9 +444,9 @@ func (s *MethodTestSuite) TestWorkspaceProxy() {
ID: uuid.New(),
}).Asserts(rbac.ResourceWorkspaceProxy, rbac.ActionCreate)
}))
s.Run("UpdateWorkspaceProxy", s.Subtest(func(db database.Store, check *expects) {
s.Run("RegisterWorkspaceProxy", s.Subtest(func(db database.Store, check *expects) {
p, _ := dbgen.WorkspaceProxy(s.T(), db, database.WorkspaceProxy{})
check.Args(database.UpdateWorkspaceProxyParams{
check.Args(database.RegisterWorkspaceProxyParams{
ID: p.ID,
}).Asserts(p, rbac.ActionUpdate)
}))

View File

@ -5127,6 +5127,21 @@ func (q *fakeQuerier) GetWorkspaceProxyByID(_ context.Context, id uuid.UUID) (da
return database.WorkspaceProxy{}, sql.ErrNoRows
}
func (q *fakeQuerier) GetWorkspaceProxyByName(_ context.Context, name string) (database.WorkspaceProxy, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
for _, proxy := range q.workspaceProxies {
if proxy.Deleted {
continue
}
if proxy.Name == name {
return proxy, nil
}
}
return database.WorkspaceProxy{}, sql.ErrNoRows
}
func (q *fakeQuerier) GetWorkspaceProxyByHostname(_ context.Context, hostname string) (database.WorkspaceProxy, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
@ -5187,14 +5202,12 @@ func (q *fakeQuerier) InsertWorkspaceProxy(_ context.Context, arg database.Inser
return p, nil
}
func (q *fakeQuerier) UpdateWorkspaceProxy(_ context.Context, arg database.UpdateWorkspaceProxyParams) (database.WorkspaceProxy, error) {
func (q *fakeQuerier) RegisterWorkspaceProxy(_ context.Context, arg database.RegisterWorkspaceProxyParams) (database.WorkspaceProxy, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
for i, p := range q.workspaceProxies {
if p.ID == arg.ID {
p.Name = arg.Name
p.Icon = arg.Icon
p.Url = arg.Url
p.WildcardHostname = arg.WildcardHostname
p.UpdatedAt = database.Now()

View File

@ -158,6 +158,7 @@ type sqlcQuerier interface {
//
GetWorkspaceProxyByHostname(ctx context.Context, hostname string) (WorkspaceProxy, error)
GetWorkspaceProxyByID(ctx context.Context, id uuid.UUID) (WorkspaceProxy, error)
GetWorkspaceProxyByName(ctx context.Context, name string) (WorkspaceProxy, error)
GetWorkspaceResourceByID(ctx context.Context, id uuid.UUID) (WorkspaceResource, error)
GetWorkspaceResourceMetadataByResourceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceResourceMetadatum, error)
GetWorkspaceResourceMetadataCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceResourceMetadatum, error)
@ -209,6 +210,7 @@ type sqlcQuerier interface {
InsertWorkspaceResourceMetadata(ctx context.Context, arg InsertWorkspaceResourceMetadataParams) ([]WorkspaceResourceMetadatum, error)
ParameterValue(ctx context.Context, id uuid.UUID) (ParameterValue, error)
ParameterValues(ctx context.Context, arg ParameterValuesParams) ([]ParameterValue, error)
RegisterWorkspaceProxy(ctx context.Context, arg RegisterWorkspaceProxyParams) (WorkspaceProxy, error)
// Non blocking lock. Returns true if the lock was acquired, false otherwise.
//
// This must be called from within a transaction. The lock will be automatically
@ -253,7 +255,6 @@ type sqlcQuerier interface {
UpdateWorkspaceBuildCostByID(ctx context.Context, arg UpdateWorkspaceBuildCostByIDParams) (WorkspaceBuild, error)
UpdateWorkspaceDeletedByID(ctx context.Context, arg UpdateWorkspaceDeletedByIDParams) error
UpdateWorkspaceLastUsedAt(ctx context.Context, arg UpdateWorkspaceLastUsedAtParams) error
UpdateWorkspaceProxy(ctx context.Context, arg UpdateWorkspaceProxyParams) (WorkspaceProxy, error)
UpdateWorkspaceProxyDeleted(ctx context.Context, arg UpdateWorkspaceProxyDeletedParams) error
UpdateWorkspaceTTL(ctx context.Context, arg UpdateWorkspaceTTLParams) error
UpdateWorkspaceTTLToBeWithinTemplateMax(ctx context.Context, arg UpdateWorkspaceTTLToBeWithinTemplateMaxParams) error

View File

@ -2937,6 +2937,36 @@ func (q *sqlQuerier) GetWorkspaceProxyByID(ctx context.Context, id uuid.UUID) (W
return i, err
}
const getWorkspaceProxyByName = `-- name: GetWorkspaceProxyByName :one
SELECT
id, name, display_name, icon, url, wildcard_hostname, created_at, updated_at, deleted, token_hashed_secret
FROM
workspace_proxies
WHERE
name = $1
AND deleted = false
LIMIT
1
`
func (q *sqlQuerier) GetWorkspaceProxyByName(ctx context.Context, name string) (WorkspaceProxy, error) {
row := q.db.QueryRowContext(ctx, getWorkspaceProxyByName, name)
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 insertWorkspaceProxy = `-- name: InsertWorkspaceProxy :one
INSERT INTO
workspace_proxies (
@ -2995,39 +3025,26 @@ func (q *sqlQuerier) InsertWorkspaceProxy(ctx context.Context, arg InsertWorkspa
return i, err
}
const updateWorkspaceProxy = `-- name: UpdateWorkspaceProxy :one
const registerWorkspaceProxy = `-- name: RegisterWorkspaceProxy :one
UPDATE
workspace_proxies
SET
name = $1,
display_name = $2,
url = $3,
wildcard_hostname = $4,
icon = $5,
url = $1,
wildcard_hostname = $2,
updated_at = Now()
WHERE
id = $6
id = $3
RETURNING id, name, display_name, icon, url, wildcard_hostname, created_at, updated_at, deleted, token_hashed_secret
`
type UpdateWorkspaceProxyParams struct {
Name string `db:"name" json:"name"`
DisplayName string `db:"display_name" json:"display_name"`
type RegisterWorkspaceProxyParams struct {
Url string `db:"url" json:"url"`
WildcardHostname string `db:"wildcard_hostname" json:"wildcard_hostname"`
Icon string `db:"icon" json:"icon"`
ID uuid.UUID `db:"id" json:"id"`
}
func (q *sqlQuerier) UpdateWorkspaceProxy(ctx context.Context, arg UpdateWorkspaceProxyParams) (WorkspaceProxy, error) {
row := q.db.QueryRowContext(ctx, updateWorkspaceProxy,
arg.Name,
arg.DisplayName,
arg.Url,
arg.WildcardHostname,
arg.Icon,
arg.ID,
)
func (q *sqlQuerier) RegisterWorkspaceProxy(ctx context.Context, arg RegisterWorkspaceProxyParams) (WorkspaceProxy, error) {
row := q.db.QueryRowContext(ctx, registerWorkspaceProxy, arg.Url, arg.WildcardHostname, arg.ID)
var i WorkspaceProxy
err := row.Scan(
&i.ID,

View File

@ -15,15 +15,12 @@ INSERT INTO
VALUES
($1, $2, $3, $4, $5, $6, $7, $8, $9, false) RETURNING *;
-- name: UpdateWorkspaceProxy :one
-- name: RegisterWorkspaceProxy :one
UPDATE
workspace_proxies
SET
name = @name,
display_name = @display_name,
url = @url,
wildcard_hostname = @wildcard_hostname,
icon = @icon,
updated_at = Now()
WHERE
id = @id
@ -49,6 +46,17 @@ WHERE
LIMIT
1;
-- name: GetWorkspaceProxyByName :one
SELECT
*
FROM
workspace_proxies
WHERE
name = $1
AND deleted = false
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.

View File

@ -8,6 +8,7 @@ import (
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"golang.org/x/xerrors"
@ -156,3 +157,53 @@ func ExtractWorkspaceProxy(opts ExtractWorkspaceProxyConfig) func(http.Handler)
})
}
}
type workspaceProxyParamContextKey struct{}
// WorkspaceProxyParam returns the worksace proxy from the ExtractWorkspaceProxyParam handler.
func WorkspaceProxyParam(r *http.Request) database.WorkspaceProxy {
user, ok := r.Context().Value(workspaceProxyParamContextKey{}).(database.WorkspaceProxy)
if !ok {
panic("developer error: workspace proxy parameter middleware not provided")
}
return user
}
// ExtractWorkspaceProxyParam extracts a workspace proxy from an ID/name in the {workspaceproxy} URL
// parameter.
//
//nolint:revive
func ExtractWorkspaceProxyParam(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()
proxyQuery := chi.URLParam(r, "workspaceproxy")
if proxyQuery == "" {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "\"workspaceproxy\" must be provided.",
})
return
}
var proxy database.WorkspaceProxy
var dbErr error
if proxyID, err := uuid.Parse(proxyQuery); err == nil {
proxy, dbErr = db.GetWorkspaceProxyByID(ctx, proxyID)
} else {
proxy, dbErr = db.GetWorkspaceProxyByName(ctx, proxyQuery)
}
if httpapi.Is404Error(dbErr) {
httpapi.ResourceNotFound(rw)
return
}
if dbErr != nil {
httpapi.InternalServerError(rw, dbErr)
return
}
ctx = context.WithValue(ctx, workspaceProxyParamContextKey{}, proxy)
next.ServeHTTP(rw, r.WithContext(ctx))
})
}
}

View File

@ -7,6 +7,7 @@ import (
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
@ -160,4 +161,106 @@ func TestExtractWorkspaceProxy(t *testing.T) {
defer res.Body.Close()
require.Equal(t, http.StatusOK, res.StatusCode)
})
t.Run("Deleted", 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{})
)
err := db.UpdateWorkspaceProxyDeleted(context.Background(), database.UpdateWorkspaceProxyDeletedParams{
ID: proxy.ID,
Deleted: true,
})
require.NoError(t, err, "failed to delete workspace proxy")
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)
})
}
func TestExtractWorkspaceProxyParam(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("OKName", 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{})
)
routeContext := chi.NewRouteContext()
routeContext.URLParams.Add("workspaceproxy", proxy.Name)
r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, routeContext))
httpmw.ExtractWorkspaceProxyParam(db)(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
// Checks that it exists on the context!
_ = httpmw.WorkspaceProxyParam(request)
successHandler.ServeHTTP(writer, request)
})).ServeHTTP(rw, r)
res := rw.Result()
defer res.Body.Close()
require.Equal(t, http.StatusOK, res.StatusCode)
})
t.Run("OKID", 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{})
)
routeContext := chi.NewRouteContext()
routeContext.URLParams.Add("workspaceproxy", proxy.ID.String())
r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, routeContext))
httpmw.ExtractWorkspaceProxyParam(db)(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
// Checks that it exists on the context!
_ = httpmw.WorkspaceProxyParam(request)
successHandler.ServeHTTP(writer, request)
})).ServeHTTP(rw, r)
res := rw.Result()
defer res.Body.Close()
require.Equal(t, http.StatusOK, res.StatusCode)
})
t.Run("NotFound", func(t *testing.T) {
t.Parallel()
var (
db = dbfake.New()
r = httptest.NewRequest("GET", "/", nil)
rw = httptest.NewRecorder()
)
routeContext := chi.NewRouteContext()
routeContext.URLParams.Add("workspaceproxy", uuid.NewString())
r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, routeContext))
httpmw.ExtractWorkspaceProxyParam(db)(successHandler).ServeHTTP(rw, r)
res := rw.Result()
defer res.Body.Close()
require.Equal(t, http.StatusNotFound, res.StatusCode)
})
}

View File

@ -54,6 +54,10 @@ func (t SignedToken) MatchesRequest(req Request) bool {
// two keys.
type SecurityKey [96]byte
func (k SecurityKey) String() string {
return hex.EncodeToString(k[:])
}
func (k SecurityKey) signingKey() []byte {
return k[:64]
}

View File

@ -333,12 +333,22 @@ type DangerousConfig struct {
}
const (
flagEnterpriseKey = "enterprise"
flagSecretKey = "secret"
annotationEnterpriseKey = "enterprise"
annotationSecretKey = "secret"
// annotationExternalProxies is used to mark options that are used by workspace
// proxies. This is used to filter out options that are not relevant.
annotationExternalProxies = "external_workspace_proxies"
)
// IsWorkspaceProxies returns true if the cli option is used by workspace proxies.
func IsWorkspaceProxies(opt clibase.Option) bool {
// If it is a bool, use the bool value.
b, _ := strconv.ParseBool(opt.Annotations[annotationExternalProxies])
return b
}
func IsSecretDeploymentOption(opt clibase.Option) bool {
return opt.Annotations.IsSet(flagSecretKey)
return opt.Annotations.IsSet(annotationSecretKey)
}
func DefaultCacheDir() string {
@ -470,6 +480,7 @@ when required by your organization's security policy.`,
Value: &c.HTTPAddress,
Group: &deploymentGroupNetworkingHTTP,
YAML: "httpAddress",
Annotations: clibase.Annotations{}.Mark(annotationExternalProxies, "true"),
}
tlsBindAddress := clibase.Option{
Name: "TLS Address",
@ -480,6 +491,7 @@ when required by your organization's security policy.`,
Value: &c.TLS.Address,
Group: &deploymentGroupNetworkingTLS,
YAML: "address",
Annotations: clibase.Annotations{}.Mark(annotationExternalProxies, "true"),
}
redirectToAccessURL := clibase.Option{
Name: "Redirect to Access URL",
@ -499,6 +511,7 @@ when required by your organization's security policy.`,
Env: "CODER_ACCESS_URL",
Group: &deploymentGroupNetworking,
YAML: "accessURL",
Annotations: clibase.Annotations{}.Mark(annotationExternalProxies, "true"),
},
{
Name: "Wildcard Access URL",
@ -508,6 +521,7 @@ when required by your organization's security policy.`,
Value: &c.WildcardAccessURL,
Group: &deploymentGroupNetworking,
YAML: "wildcardAccessURL",
Annotations: clibase.Annotations{}.Mark(annotationExternalProxies, "true"),
},
redirectToAccessURL,
{
@ -534,7 +548,8 @@ when required by your organization's security policy.`,
httpAddress,
tlsBindAddress,
},
Group: &deploymentGroupNetworking,
Group: &deploymentGroupNetworking,
Annotations: clibase.Annotations{}.Mark(annotationExternalProxies, "true"),
},
// TLS settings
{
@ -545,6 +560,7 @@ when required by your organization's security policy.`,
Value: &c.TLS.Enable,
Group: &deploymentGroupNetworkingTLS,
YAML: "enable",
Annotations: clibase.Annotations{}.Mark(annotationExternalProxies, "true"),
},
{
Name: "Redirect HTTP to HTTPS",
@ -557,6 +573,7 @@ when required by your organization's security policy.`,
UseInstead: clibase.OptionSet{redirectToAccessURL},
Group: &deploymentGroupNetworkingTLS,
YAML: "redirectHTTP",
Annotations: clibase.Annotations{}.Mark(annotationExternalProxies, "true"),
},
{
Name: "TLS Certificate Files",
@ -566,6 +583,7 @@ when required by your organization's security policy.`,
Value: &c.TLS.CertFiles,
Group: &deploymentGroupNetworkingTLS,
YAML: "certFiles",
Annotations: clibase.Annotations{}.Mark(annotationExternalProxies, "true"),
},
{
Name: "TLS Client CA Files",
@ -575,6 +593,7 @@ when required by your organization's security policy.`,
Value: &c.TLS.ClientCAFile,
Group: &deploymentGroupNetworkingTLS,
YAML: "clientCAFile",
Annotations: clibase.Annotations{}.Mark(annotationExternalProxies, "true"),
},
{
Name: "TLS Client Auth",
@ -585,6 +604,7 @@ when required by your organization's security policy.`,
Value: &c.TLS.ClientAuth,
Group: &deploymentGroupNetworkingTLS,
YAML: "clientAuth",
Annotations: clibase.Annotations{}.Mark(annotationExternalProxies, "true"),
},
{
Name: "TLS Key Files",
@ -594,6 +614,7 @@ when required by your organization's security policy.`,
Value: &c.TLS.KeyFiles,
Group: &deploymentGroupNetworkingTLS,
YAML: "keyFiles",
Annotations: clibase.Annotations{}.Mark(annotationExternalProxies, "true"),
},
{
Name: "TLS Minimum Version",
@ -604,6 +625,7 @@ when required by your organization's security policy.`,
Value: &c.TLS.MinVersion,
Group: &deploymentGroupNetworkingTLS,
YAML: "minVersion",
Annotations: clibase.Annotations{}.Mark(annotationExternalProxies, "true"),
},
{
Name: "TLS Client Cert File",
@ -613,6 +635,7 @@ when required by your organization's security policy.`,
Value: &c.TLS.ClientCertFile,
Group: &deploymentGroupNetworkingTLS,
YAML: "clientCertFile",
Annotations: clibase.Annotations{}.Mark(annotationExternalProxies, "true"),
},
{
Name: "TLS Client Key File",
@ -622,6 +645,7 @@ when required by your organization's security policy.`,
Value: &c.TLS.ClientKeyFile,
Group: &deploymentGroupNetworkingTLS,
YAML: "clientKeyFile",
Annotations: clibase.Annotations{}.Mark(annotationExternalProxies, "true"),
},
// Derp settings
{
@ -679,7 +703,7 @@ when required by your organization's security policy.`,
Description: "An HTTP URL that is accessible by other replicas to relay DERP traffic. Required for high availability.",
Flag: "derp-server-relay-url",
Env: "CODER_DERP_SERVER_RELAY_URL",
Annotations: clibase.Annotations{}.Mark(flagEnterpriseKey, "true"),
Annotations: clibase.Annotations{}.Mark(annotationEnterpriseKey, "true"),
Value: &c.DERP.Server.RelayURL,
Group: &deploymentGroupNetworkingDERP,
YAML: "relayURL",
@ -712,6 +736,7 @@ when required by your organization's security policy.`,
Value: &c.Prometheus.Enable,
Group: &deploymentGroupIntrospectionPrometheus,
YAML: "enable",
Annotations: clibase.Annotations{}.Mark(annotationExternalProxies, "true"),
},
{
Name: "Prometheus Address",
@ -722,6 +747,7 @@ when required by your organization's security policy.`,
Value: &c.Prometheus.Address,
Group: &deploymentGroupIntrospectionPrometheus,
YAML: "address",
Annotations: clibase.Annotations{}.Mark(annotationExternalProxies, "true"),
},
{
Name: "Prometheus Collect Agent Stats",
@ -741,6 +767,7 @@ when required by your organization's security policy.`,
Value: &c.Pprof.Enable,
Group: &deploymentGroupIntrospectionPPROF,
YAML: "enable",
Annotations: clibase.Annotations{}.Mark(annotationExternalProxies, "true"),
},
{
Name: "pprof Address",
@ -751,6 +778,7 @@ when required by your organization's security policy.`,
Value: &c.Pprof.Address,
Group: &deploymentGroupIntrospectionPPROF,
YAML: "address",
Annotations: clibase.Annotations{}.Mark(annotationExternalProxies, "true"),
},
// oAuth settings
{
@ -768,7 +796,7 @@ when required by your organization's security policy.`,
Flag: "oauth2-github-client-secret",
Env: "CODER_OAUTH2_GITHUB_CLIENT_SECRET",
Value: &c.OAuth2.Github.ClientSecret,
Annotations: clibase.Annotations{}.Mark(flagSecretKey, "true"),
Annotations: clibase.Annotations{}.Mark(annotationSecretKey, "true"),
Group: &deploymentGroupOAuth2GitHub,
},
{
@ -841,7 +869,7 @@ when required by your organization's security policy.`,
Description: "Client secret to use for Login with OIDC.",
Flag: "oidc-client-secret",
Env: "CODER_OIDC_CLIENT_SECRET",
Annotations: clibase.Annotations{}.Mark(flagSecretKey, "true"),
Annotations: clibase.Annotations{}.Mark(annotationSecretKey, "true"),
Value: &c.OIDC.ClientSecret,
Group: &deploymentGroupOIDC,
},
@ -1007,13 +1035,14 @@ when required by your organization's security policy.`,
Value: &c.Trace.Enable,
Group: &deploymentGroupIntrospectionTracing,
YAML: "enable",
Annotations: clibase.Annotations{}.Mark(annotationExternalProxies, "true"),
},
{
Name: "Trace Honeycomb API Key",
Description: "Enables trace exporting to Honeycomb.io using the provided API Key.",
Flag: "trace-honeycomb-api-key",
Env: "CODER_TRACE_HONEYCOMB_API_KEY",
Annotations: clibase.Annotations{}.Mark(flagSecretKey, "true"),
Annotations: clibase.Annotations{}.Mark(annotationSecretKey, "true").Mark(annotationExternalProxies, "true"),
Value: &c.Trace.HoneycombAPIKey,
Group: &deploymentGroupIntrospectionTracing,
},
@ -1025,6 +1054,7 @@ when required by your organization's security policy.`,
Value: &c.Trace.CaptureLogs,
Group: &deploymentGroupIntrospectionTracing,
YAML: "captureLogs",
Annotations: clibase.Annotations{}.Mark(annotationExternalProxies, "true"),
},
// Provisioner settings
{
@ -1074,19 +1104,21 @@ when required by your organization's security policy.`,
Flag: "dangerous-disable-rate-limits",
Env: "CODER_DANGEROUS_DISABLE_RATE_LIMITS",
Value: &c.RateLimit.DisableAll,
Hidden: true,
Value: &c.RateLimit.DisableAll,
Hidden: true,
Annotations: clibase.Annotations{}.Mark(annotationExternalProxies, "true"),
},
{
Name: "API Rate Limit",
Description: "Maximum number of requests per minute allowed to the API per user, or per IP address for unauthenticated users. Negative values mean no rate limit. Some API endpoints have separate strict rate limits regardless of this value to prevent denial-of-service or brute force attacks.",
// Change the env from the auto-generated CODER_RATE_LIMIT_API to the
// old value to avoid breaking existing deployments.
Env: "CODER_API_RATE_LIMIT",
Flag: "api-rate-limit",
Default: "512",
Value: &c.RateLimit.API,
Hidden: true,
Env: "CODER_API_RATE_LIMIT",
Flag: "api-rate-limit",
Default: "512",
Value: &c.RateLimit.API,
Hidden: true,
Annotations: clibase.Annotations{}.Mark(annotationExternalProxies, "true"),
},
// Logging settings
{
@ -1096,9 +1128,10 @@ when required by your organization's security policy.`,
Env: "CODER_VERBOSE",
FlagShorthand: "v",
Value: &c.Verbose,
Group: &deploymentGroupIntrospectionLogging,
YAML: "verbose",
Value: &c.Verbose,
Group: &deploymentGroupIntrospectionLogging,
YAML: "verbose",
Annotations: clibase.Annotations{}.Mark(annotationExternalProxies, "true"),
},
{
Name: "Human Log Location",
@ -1109,6 +1142,7 @@ when required by your organization's security policy.`,
Value: &c.Logging.Human,
Group: &deploymentGroupIntrospectionLogging,
YAML: "humanPath",
Annotations: clibase.Annotations{}.Mark(annotationExternalProxies, "true"),
},
{
Name: "JSON Log Location",
@ -1119,6 +1153,7 @@ when required by your organization's security policy.`,
Value: &c.Logging.JSON,
Group: &deploymentGroupIntrospectionLogging,
YAML: "jsonPath",
Annotations: clibase.Annotations{}.Mark(annotationExternalProxies, "true"),
},
{
Name: "Stackdriver Log Location",
@ -1129,6 +1164,7 @@ when required by your organization's security policy.`,
Value: &c.Logging.Stackdriver,
Group: &deploymentGroupIntrospectionLogging,
YAML: "stackdriverPath",
Annotations: clibase.Annotations{}.Mark(annotationExternalProxies, "true"),
},
// ☢️ Dangerous settings
{
@ -1157,6 +1193,7 @@ when required by your organization's security policy.`,
Env: "CODER_EXPERIMENTS",
Value: &c.Experiments,
YAML: "experiments",
Annotations: clibase.Annotations{}.Mark(annotationExternalProxies, "true"),
},
{
Name: "Update Check",
@ -1199,6 +1236,7 @@ when required by your organization's security policy.`,
Value: &c.ProxyTrustedHeaders,
Group: &deploymentGroupNetworking,
YAML: "proxyTrustedHeaders",
Annotations: clibase.Annotations{}.Mark(annotationExternalProxies, "true"),
},
{
Name: "Proxy Trusted Origins",
@ -1208,6 +1246,7 @@ when required by your organization's security policy.`,
Value: &c.ProxyTrustedOrigins,
Group: &deploymentGroupNetworking,
YAML: "proxyTrustedOrigins",
Annotations: clibase.Annotations{}.Mark(annotationExternalProxies, "true"),
},
{
Name: "Cache Directory",
@ -1232,7 +1271,7 @@ when required by your organization's security policy.`,
Description: "URL of a PostgreSQL database. If empty, PostgreSQL binaries will be downloaded from Maven (https://repo1.maven.org/maven2) and store all data in the config root. Access the built-in database with \"coder server postgres-builtin-url\".",
Flag: "postgres-url",
Env: "CODER_PG_CONNECTION_URL",
Annotations: clibase.Annotations{}.Mark(flagSecretKey, "true"),
Annotations: clibase.Annotations{}.Mark(annotationSecretKey, "true"),
Value: &c.PostgresURL,
},
{
@ -1243,28 +1282,31 @@ when required by your organization's security policy.`,
Value: &c.SecureAuthCookie,
Group: &deploymentGroupNetworking,
YAML: "secureAuthCookie",
Annotations: clibase.Annotations{}.Mark(annotationExternalProxies, "true"),
},
{
Name: "Strict-Transport-Security",
Description: "Controls if the 'Strict-Transport-Security' header is set on all static file responses. " +
"This header should only be set if the server is accessed via HTTPS. This value is the MaxAge in seconds of " +
"the header.",
Default: "0",
Flag: "strict-transport-security",
Env: "CODER_STRICT_TRANSPORT_SECURITY",
Value: &c.StrictTransportSecurity,
Group: &deploymentGroupNetworkingTLS,
YAML: "strictTransportSecurity",
Default: "0",
Flag: "strict-transport-security",
Env: "CODER_STRICT_TRANSPORT_SECURITY",
Value: &c.StrictTransportSecurity,
Group: &deploymentGroupNetworkingTLS,
YAML: "strictTransportSecurity",
Annotations: clibase.Annotations{}.Mark(annotationExternalProxies, "true"),
},
{
Name: "Strict-Transport-Security Options",
Description: "Two optional fields can be set in the Strict-Transport-Security header; 'includeSubDomains' and 'preload'. " +
"The 'strict-transport-security' flag must be set to a non-zero value for these options to be used.",
Flag: "strict-transport-security-options",
Env: "CODER_STRICT_TRANSPORT_SECURITY_OPTIONS",
Value: &c.StrictTransportSecurityOptions,
Group: &deploymentGroupNetworkingTLS,
YAML: "strictTransportSecurityOptions",
Flag: "strict-transport-security-options",
Env: "CODER_STRICT_TRANSPORT_SECURITY_OPTIONS",
Value: &c.StrictTransportSecurityOptions,
Group: &deploymentGroupNetworkingTLS,
YAML: "strictTransportSecurityOptions",
Annotations: clibase.Annotations{}.Mark(annotationExternalProxies, "true"),
},
{
Name: "SSH Keygen Algorithm",
@ -1308,7 +1350,7 @@ when required by your organization's security policy.`,
Description: "Whether Coder only allows connections to workspaces via the browser.",
Flag: "browser-only",
Env: "CODER_BROWSER_ONLY",
Annotations: clibase.Annotations{}.Mark(flagEnterpriseKey, "true"),
Annotations: clibase.Annotations{}.Mark(annotationEnterpriseKey, "true").Mark(annotationExternalProxies, "true"),
Value: &c.BrowserOnly,
Group: &deploymentGroupNetworking,
YAML: "browserOnly",
@ -1318,7 +1360,7 @@ when required by your organization's security policy.`,
Description: "Enables SCIM and sets the authentication header for the built-in SCIM server. New users are automatically created with OIDC authentication.",
Flag: "scim-auth-header",
Env: "CODER_SCIM_AUTH_HEADER",
Annotations: clibase.Annotations{}.Mark(flagEnterpriseKey, "true").Mark(flagSecretKey, "true"),
Annotations: clibase.Annotations{}.Mark(annotationEnterpriseKey, "true").Mark(annotationSecretKey, "true"),
Value: &c.SCIMAPIKey,
},
@ -1328,8 +1370,9 @@ when required by your organization's security policy.`,
Flag: "disable-path-apps",
Env: "CODER_DISABLE_PATH_APPS",
Value: &c.DisablePathApps,
YAML: "disablePathApps",
Value: &c.DisablePathApps,
YAML: "disablePathApps",
Annotations: clibase.Annotations{}.Mark(annotationExternalProxies, "true"),
},
{
Name: "Disable Owner Workspace Access",
@ -1337,8 +1380,9 @@ when required by your organization's security policy.`,
Flag: "disable-owner-workspace-access",
Env: "CODER_DISABLE_OWNER_WORKSPACE_ACCESS",
Value: &c.DisableOwnerWorkspaceExec,
YAML: "disableOwnerWorkspaceAccess",
Value: &c.DisableOwnerWorkspaceExec,
YAML: "disableOwnerWorkspaceAccess",
Annotations: clibase.Annotations{}.Mark(annotationExternalProxies, "true"),
},
{
Name: "Session Duration",
@ -1407,10 +1451,11 @@ when required by your organization's security policy.`,
Name: "Write Config",
Description: `
Write out the current server config as YAML to stdout.`,
Flag: "write-config",
Group: &deploymentGroupConfig,
Hidden: false,
Value: &c.WriteConfig,
Flag: "write-config",
Group: &deploymentGroupConfig,
Hidden: false,
Value: &c.WriteConfig,
Annotations: clibase.Annotations{}.Mark(annotationExternalProxies, "true"),
},
{
Name: "Support Links",

View File

@ -3,6 +3,7 @@ package codersdk
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
@ -12,16 +13,16 @@ import (
)
type WorkspaceProxy struct {
ID uuid.UUID `db:"id" json:"id" format:"uuid"`
Name string `db:"name" json:"name"`
Icon string `db:"icon" json:"icon"`
ID uuid.UUID `db:"id" json:"id" format:"uuid" table:"id"`
Name string `db:"name" json:"name" table:"name,default_sort"`
Icon string `db:"icon" json:"icon" table:"icon"`
// Full url including scheme of the proxy api url: https://us.example.com
URL string `db:"url" json:"url"`
URL string `db:"url" json:"url" table:"url"`
// WildcardHostname 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" format:"date-time"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at" format:"date-time"`
Deleted bool `db:"deleted" json:"deleted"`
WildcardHostname string `db:"wildcard_hostname" json:"wildcard_hostname" table:"wildcard_hostname"`
CreatedAt time.Time `db:"created_at" json:"created_at" format:"date-time" table:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at" format:"date-time" table:"updated_at"`
Deleted bool `db:"deleted" json:"deleted" table:"deleted"`
}
type CreateWorkspaceProxyRequest struct {
@ -33,8 +34,9 @@ type CreateWorkspaceProxyRequest struct {
}
type CreateWorkspaceProxyResponse struct {
Proxy WorkspaceProxy `json:"proxy"`
ProxyToken string `json:"proxy_token"`
Proxy WorkspaceProxy `json:"proxy" table:"proxy,recursive"`
// The recursive table sort is not working very well.
ProxyToken string `json:"proxy_token" table:"proxy token,default_sort"`
}
func (c *Client) CreateWorkspaceProxy(ctx context.Context, req CreateWorkspaceProxyRequest) (CreateWorkspaceProxyResponse, error) {
@ -71,3 +73,24 @@ func (c *Client) WorkspaceProxies(ctx context.Context) ([]WorkspaceProxy, error)
var proxies []WorkspaceProxy
return proxies, json.NewDecoder(res.Body).Decode(&proxies)
}
func (c *Client) DeleteWorkspaceProxyByName(ctx context.Context, name string) error {
res, err := c.Request(ctx, http.MethodDelete,
fmt.Sprintf("/api/v2/workspaceproxies/%s", name),
nil,
)
if err != nil {
return xerrors.Errorf("make request: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return ReadBodyAsError(res)
}
return nil
}
func (c *Client) DeleteWorkspaceProxyByID(ctx context.Context, id uuid.UUID) error {
return c.DeleteWorkspaceProxyByName(ctx, id.String())
}

View File

@ -1272,3 +1272,47 @@ curl -X POST http://coder-server:8080/api/v2/workspaceproxies \
| 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).
## Delete workspace proxy
### Code samples
```shell
# Example request using curl
curl -X DELETE http://coder-server:8080/api/v2/workspaceproxies/{workspaceproxy} \
-H 'Accept: application/json' \
-H 'Coder-Session-Token: API_KEY'
```
`DELETE /workspaceproxies/{workspaceproxy}`
### Parameters
| Name | In | Type | Required | Description |
| ---------------- | ---- | ------------ | -------- | ---------------- |
| `workspaceproxy` | path | string(uuid) | true | Proxy ID or name |
### Example responses
> 200 Response
```json
{
"detail": "string",
"message": "string",
"validations": [
{
"detail": "string",
"field": "string"
}
]
}
```
### Responses
| Status | Meaning | Description | Schema |
| ------ | ------------------------------------------------------- | ----------- | ------------------------------------------------ |
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.Response](schemas.md#codersdkresponse) |
To perform this operation, you must be authenticated. [Learn more](authentication.md).

View File

@ -6449,3 +6449,33 @@ _None_
| Name | Type | Required | Restrictions | Description |
| ------------------ | ------ | -------- | ------------ | ----------------------------------------------------------- |
| `signed_token_str` | string | false | | Signed token str should be set as a cookie on the response. |
## wsproxysdk.RegisterWorkspaceProxyRequest
```json
{
"access_url": "string",
"wildcard_hostname": "string"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
| ------------------- | ------ | -------- | ------------ | ----------------------------------------------------------------------------- |
| `access_url` | string | false | | Access URL that hits the workspace proxy api. |
| `wildcard_hostname` | string | false | | Wildcard hostname that the workspace proxy api is serving for subdomain apps. |
## wsproxysdk.RegisterWorkspaceProxyResponse
```json
{
"app_security_key": "string"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
| ------------------ | ------ | -------- | ------------ | ----------- |
| `app_security_key` | string | false | | |

View File

@ -0,0 +1,346 @@
//go:build !slim
package cli
import (
"context"
"fmt"
"io"
"log"
"net"
"net/http"
"net/http/pprof"
"os/signal"
"regexp"
rpprof "runtime/pprof"
"time"
"github.com/coreos/go-systemd/daemon"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/collectors"
"github.com/prometheus/client_golang/prometheus/promhttp"
"golang.org/x/xerrors"
"github.com/coder/coder/cli"
"github.com/coder/coder/cli/clibase"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/httpmw"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/enterprise/wsproxy"
)
type closers []func()
func (c closers) Close() {
for _, closeF := range c {
closeF()
}
}
func (c *closers) Add(f func()) {
*c = append(*c, f)
}
func (*RootCmd) proxyServer() *clibase.Cmd {
var (
cfg = new(codersdk.DeploymentValues)
// Filter options for only relevant ones.
opts = cfg.Options().Filter(codersdk.IsWorkspaceProxies)
externalProxyOptionGroup = clibase.Group{
Name: "External Workspace Proxy",
YAML: "externalWorkspaceProxy",
}
proxySessionToken clibase.String
primaryAccessURL clibase.URL
)
opts.Add(
// Options only for external workspace proxies
clibase.Option{
Name: "Proxy Session Token",
Description: "Authentication token for the workspace proxy to communicate with coderd.",
Flag: "proxy-session-token",
Env: "CODER_PROXY_SESSION_TOKEN",
YAML: "proxySessionToken",
Default: "",
Value: &proxySessionToken,
Group: &externalProxyOptionGroup,
Hidden: false,
},
clibase.Option{
Name: "Coderd (Primary) Access URL",
Description: "URL to communicate with coderd. This should match the access URL of the Coder deployment.",
Flag: "primary-access-url",
Env: "CODER_PRIMARY_ACCESS_URL",
YAML: "primaryAccessURL",
Default: "",
Value: &primaryAccessURL,
Group: &externalProxyOptionGroup,
Hidden: false,
},
)
cmd := &clibase.Cmd{
Use: "server",
Short: "Start a workspace proxy server",
Options: opts,
Middleware: clibase.Chain(
cli.WriteConfigMW(cfg),
cli.PrintDeprecatedOptions(),
clibase.RequireNArgs(0),
),
Handler: func(inv *clibase.Invocation) error {
if !(primaryAccessURL.Scheme == "http" || primaryAccessURL.Scheme == "https") {
return xerrors.Errorf("primary access URL must be http or https: url=%s", primaryAccessURL.String())
}
var closers closers
// Main command context for managing cancellation of running
// services.
ctx, topCancel := context.WithCancel(inv.Context())
defer topCancel()
closers.Add(topCancel)
go cli.DumpHandler(ctx)
cli.PrintLogo(inv)
logger, logCloser, err := cli.BuildLogger(inv, cfg)
if err != nil {
return xerrors.Errorf("make logger: %w", err)
}
defer logCloser()
closers.Add(logCloser)
logger.Debug(ctx, "started debug logging")
logger.Sync()
// Register signals early on so that graceful shutdown can't
// be interrupted by additional signals. Note that we avoid
// shadowing cancel() (from above) here because notifyStop()
// restores default behavior for the signals. This protects
// the shutdown sequence from abruptly terminating things
// like: database migrations, provisioner work, workspace
// cleanup in dev-mode, etc.
//
// To get out of a graceful shutdown, the user can send
// SIGQUIT with ctrl+\ or SIGKILL with `kill -9`.
notifyCtx, notifyStop := signal.NotifyContext(ctx, cli.InterruptSignals...)
defer notifyStop()
// Clean up idle connections at the end, e.g.
// embedded-postgres can leave an idle connection
// which is caught by goleaks.
defer http.DefaultClient.CloseIdleConnections()
closers.Add(http.DefaultClient.CloseIdleConnections)
tracer, _ := cli.ConfigureTraceProvider(ctx, logger, inv, cfg)
httpServers, err := cli.ConfigureHTTPServers(inv, cfg)
if err != nil {
return xerrors.Errorf("configure http(s): %w", err)
}
defer httpServers.Close()
closers.Add(httpServers.Close)
// If no access url given, use the local address.
if cfg.AccessURL.String() == "" {
// Prefer TLS
if httpServers.TLSUrl != nil {
cfg.AccessURL = clibase.URL(*httpServers.TLSUrl)
} else if httpServers.HTTPUrl != nil {
cfg.AccessURL = clibase.URL(*httpServers.HTTPUrl)
}
}
// TODO: @emyrk I find this strange that we add this to the context
// at the root here.
ctx, httpClient, err := cli.ConfigureHTTPClient(
ctx,
cfg.TLS.ClientCertFile.String(),
cfg.TLS.ClientKeyFile.String(),
cfg.TLS.ClientCAFile.String(),
)
if err != nil {
return xerrors.Errorf("configure http client: %w", err)
}
defer httpClient.CloseIdleConnections()
closers.Add(httpClient.CloseIdleConnections)
// Warn the user if the access URL appears to be a loopback address.
isLocal, err := cli.IsLocalURL(ctx, cfg.AccessURL.Value())
if isLocal || err != nil {
reason := "could not be resolved"
if isLocal {
reason = "isn't externally reachable"
}
cliui.Warnf(
inv.Stderr,
"The access URL %s %s, this may cause unexpected problems when creating workspaces. Generate a unique *.try.coder.app URL by not specifying an access URL.\n",
cliui.Styles.Field.Render(cfg.AccessURL.String()), reason,
)
}
// A newline is added before for visibility in terminal output.
cliui.Infof(inv.Stdout, "\nView the Web UI: %s", cfg.AccessURL.String())
var appHostnameRegex *regexp.Regexp
appHostname := cfg.WildcardAccessURL.String()
if appHostname != "" {
appHostnameRegex, err = httpapi.CompileHostnamePattern(appHostname)
if err != nil {
return xerrors.Errorf("parse wildcard access URL %q: %w", appHostname, err)
}
}
realIPConfig, err := httpmw.ParseRealIPConfig(cfg.ProxyTrustedHeaders, cfg.ProxyTrustedOrigins)
if err != nil {
return xerrors.Errorf("parse real ip config: %w", err)
}
if cfg.Pprof.Enable {
// This prevents the pprof import from being accidentally deleted.
// pprof has an init function that attaches itself to the default handler.
// By passing a nil handler to 'serverHandler', it will automatically use
// the default, which has pprof attached.
_ = pprof.Handler
//nolint:revive
closeFunc := cli.ServeHandler(ctx, logger, nil, cfg.Pprof.Address.String(), "pprof")
defer closeFunc()
closers.Add(closeFunc)
}
prometheusRegistry := prometheus.NewRegistry()
if cfg.Prometheus.Enable {
prometheusRegistry.MustRegister(collectors.NewGoCollector())
prometheusRegistry.MustRegister(collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}))
//nolint:revive
closeFunc := cli.ServeHandler(ctx, logger, promhttp.InstrumentMetricHandler(
prometheusRegistry, promhttp.HandlerFor(prometheusRegistry, promhttp.HandlerOpts{}),
), cfg.Prometheus.Address.String(), "prometheus")
defer closeFunc()
closers.Add(closeFunc)
}
proxy, err := wsproxy.New(ctx, &wsproxy.Options{
Logger: logger,
DashboardURL: primaryAccessURL.Value(),
AccessURL: cfg.AccessURL.Value(),
AppHostname: appHostname,
AppHostnameRegex: appHostnameRegex,
RealIPConfig: realIPConfig,
Tracing: tracer,
PrometheusRegistry: prometheusRegistry,
APIRateLimit: int(cfg.RateLimit.API.Value()),
SecureAuthCookie: cfg.SecureAuthCookie.Value(),
DisablePathApps: cfg.DisablePathApps.Value(),
ProxySessionToken: proxySessionToken.Value(),
})
if err != nil {
return xerrors.Errorf("create workspace proxy: %w", err)
}
shutdownConnsCtx, shutdownConns := context.WithCancel(ctx)
defer shutdownConns()
closers.Add(shutdownConns)
// ReadHeaderTimeout is purposefully not enabled. It caused some
// issues with websockets over the dev tunnel.
// See: https://github.com/coder/coder/pull/3730
//nolint:gosec
httpServer := &http.Server{
// These errors are typically noise like "TLS: EOF". Vault does
// similar:
// https://github.com/hashicorp/vault/blob/e2490059d0711635e529a4efcbaa1b26998d6e1c/command/server.go#L2714
ErrorLog: log.New(io.Discard, "", 0),
Handler: proxy.Handler,
BaseContext: func(_ net.Listener) context.Context {
return shutdownConnsCtx
},
}
defer func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = httpServer.Shutdown(ctx)
}()
// TODO: So this obviously is not going to work well.
errCh := make(chan error, 1)
go rpprof.Do(ctx, rpprof.Labels("service", "workspace-proxy"), func(ctx context.Context) {
errCh <- httpServers.Serve(httpServer)
})
cliui.Infof(inv.Stdout, "\n==> Logs will stream in below (press ctrl+c to gracefully exit):")
// Updates the systemd status from activating to activated.
_, err = daemon.SdNotify(false, daemon.SdNotifyReady)
if err != nil {
return xerrors.Errorf("notify systemd: %w", err)
}
// Currently there is no way to ask the server to shut
// itself down, so any exit signal will result in a non-zero
// exit of the server.
var exitErr error
select {
case exitErr = <-errCh:
case <-notifyCtx.Done():
exitErr = notifyCtx.Err()
_, _ = fmt.Fprintln(inv.Stdout, cliui.Styles.Bold.Render(
"Interrupt caught, gracefully exiting. Use ctrl+\\ to force quit",
))
}
if exitErr != nil && !xerrors.Is(exitErr, context.Canceled) {
cliui.Errorf(inv.Stderr, "Unexpected error, shutting down server: %s\n", exitErr)
}
// Begin clean shut down stage, we try to shut down services
// gracefully in an order that gives the best experience.
// This procedure should not differ greatly from the order
// of `defer`s in this function, but allows us to inform
// the user about what's going on and handle errors more
// explicitly.
_, err = daemon.SdNotify(false, daemon.SdNotifyStopping)
if err != nil {
cliui.Errorf(inv.Stderr, "Notify systemd failed: %s", err)
}
// Stop accepting new connections without interrupting
// in-flight requests, give in-flight requests 5 seconds to
// complete.
cliui.Info(inv.Stdout, "Shutting down API server..."+"\n")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
err = httpServer.Shutdown(shutdownCtx)
if err != nil {
cliui.Errorf(inv.Stderr, "API server shutdown took longer than 3s: %s\n", err)
} else {
cliui.Info(inv.Stdout, "Gracefully shut down API server\n")
}
// Cancel any remaining in-flight requests.
shutdownConns()
// Trigger context cancellation for any remaining services.
closers.Close()
switch {
case xerrors.Is(exitErr, context.DeadlineExceeded):
cliui.Warnf(inv.Stderr, "Graceful shutdown timed out")
// Errors here cause a significant number of benign CI failures.
return nil
case xerrors.Is(exitErr, context.Canceled):
return nil
case exitErr != nil:
return xerrors.Errorf("graceful shutdown: %w", exitErr)
default:
return nil
}
},
}
return cmd
}

View File

@ -0,0 +1,37 @@
//go:build slim
package cli
import (
"fmt"
"io"
"os"
"github.com/coder/coder/cli/clibase"
"github.com/coder/coder/cli/cliui"
)
func (r *RootCmd) proxyServer() *clibase.Cmd {
root := &clibase.Cmd{
Use: "server",
Short: "Start a workspace proxy server",
Aliases: []string{},
// We accept RawArgs so all commands and flags are accepted.
RawArgs: true,
Hidden: true,
Handler: func(inv *clibase.Invocation) error {
serverUnsupported(inv.Stderr)
return nil
},
}
return root
}
func serverUnsupported(w io.Writer) {
_, _ = fmt.Fprintf(w, "You are using a 'slim' build of Coder, which does not support the %s subcommand.\n", cliui.Styles.Code.Render("server"))
_, _ = fmt.Fprintln(w, "")
_, _ = fmt.Fprintln(w, "Please use a build of Coder from GitHub releases:")
_, _ = fmt.Fprintln(w, " https://github.com/coder/coder/releases")
os.Exit(1)
}

View File

@ -12,6 +12,7 @@ type RootCmd struct {
func (r *RootCmd) enterpriseOnly() []*clibase.Cmd {
return []*clibase.Cmd{
r.server(),
r.workspaceProxy(),
r.features(),
r.licenses(),
r.groups(),

View File

@ -0,0 +1,156 @@
package cli
import (
"fmt"
"golang.org/x/xerrors"
"github.com/coder/coder/cli/clibase"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/codersdk"
)
func (r *RootCmd) workspaceProxy() *clibase.Cmd {
cmd := &clibase.Cmd{
Use: "workspace-proxy",
Short: "Manage workspace proxies",
Aliases: []string{"proxy"},
Hidden: true,
Handler: func(inv *clibase.Invocation) error {
return inv.Command.HelpHandler(inv)
},
Children: []*clibase.Cmd{
r.proxyServer(),
r.createProxy(),
r.deleteProxy(),
},
}
return cmd
}
func (r *RootCmd) deleteProxy() *clibase.Cmd {
client := new(codersdk.Client)
cmd := &clibase.Cmd{
Use: "delete <name|id>",
Short: "Delete a workspace proxy",
Middleware: clibase.Chain(
clibase.RequireNArgs(1),
r.InitClient(client),
),
Handler: func(inv *clibase.Invocation) error {
ctx := inv.Context()
err := client.DeleteWorkspaceProxyByName(ctx, inv.Args[0])
if err != nil {
return xerrors.Errorf("delete workspace proxy %q: %w", inv.Args[0], err)
}
_, _ = fmt.Fprintf(inv.Stdout, "Workspace proxy %q deleted successfully\n", inv.Args[0])
return nil
},
}
return cmd
}
func (r *RootCmd) createProxy() *clibase.Cmd {
var (
proxyName string
displayName string
proxyIcon string
proxyURL string
proxyWildcardHostname string
onlyToken bool
formatter = cliui.NewOutputFormatter(
// Text formatter should be human readable.
cliui.ChangeFormatterData(cliui.TextFormat(), func(data any) (any, error) {
response, ok := data.(codersdk.CreateWorkspaceProxyResponse)
if !ok {
return nil, xerrors.Errorf("unexpected type %T", data)
}
return fmt.Sprintf("Workspace Proxy %q registered successfully\nToken: %s", response.Proxy.Name, response.ProxyToken), nil
}),
cliui.JSONFormat(),
// Table formatter expects a slice, make a slice of one.
cliui.ChangeFormatterData(cliui.TableFormat([]codersdk.CreateWorkspaceProxyResponse{}, []string{"proxy name", "proxy url", "proxy token"}),
func(data any) (any, error) {
response, ok := data.(codersdk.CreateWorkspaceProxyResponse)
if !ok {
return nil, xerrors.Errorf("unexpected type %T", data)
}
return []codersdk.CreateWorkspaceProxyResponse{response}, nil
}),
)
)
client := new(codersdk.Client)
cmd := &clibase.Cmd{
Use: "create",
Short: "Create a workspace proxy",
Middleware: clibase.Chain(
clibase.RequireNArgs(0),
r.InitClient(client),
),
Handler: func(inv *clibase.Invocation) error {
ctx := inv.Context()
resp, err := client.CreateWorkspaceProxy(ctx, codersdk.CreateWorkspaceProxyRequest{
Name: proxyName,
DisplayName: displayName,
Icon: proxyIcon,
URL: proxyURL,
WildcardHostname: proxyWildcardHostname,
})
if err != nil {
return xerrors.Errorf("create workspace proxy: %w", err)
}
var output string
if onlyToken {
output = resp.ProxyToken
} else {
output, err = formatter.Format(ctx, resp)
if err != nil {
return err
}
}
_, err = fmt.Fprintln(inv.Stdout, output)
return err
},
}
formatter.AttachOptions(&cmd.Options)
cmd.Options.Add(
clibase.Option{
Flag: "name",
Description: "Name of the proxy. This is used to identify the proxy.",
Value: clibase.StringOf(&proxyName),
},
clibase.Option{
Flag: "display-name",
Description: "Display of the proxy. If omitted, the name is reused as the display name.",
Value: clibase.StringOf(&displayName),
},
clibase.Option{
Flag: "icon",
Description: "Display icon of the proxy.",
Value: clibase.StringOf(&proxyIcon),
},
clibase.Option{
Flag: "access-url",
Description: "Access URL of the proxy.",
Value: clibase.StringOf(&proxyURL),
},
clibase.Option{
Flag: "wildcard-access-url",
Description: "(Optional) Access url of the proxy for subdomain based apps.",
Value: clibase.StringOf(&proxyWildcardHostname),
},
clibase.Option{
Flag: "only-token",
Description: "Only print the token. This is useful for scripting.",
Value: clibase.BoolOf(&onlyToken),
},
)
return cmd
}

View File

@ -0,0 +1,122 @@
package cli_test
import (
"strings"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/cli/clitest"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/enterprise/coderd/coderdenttest"
"github.com/coder/coder/enterprise/coderd/license"
"github.com/coder/coder/pty/ptytest"
"github.com/coder/coder/testutil"
)
func Test_ProxyCRUD(t *testing.T) {
t.Parallel()
t.Run("Create", func(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.Experiments = []string{
string(codersdk.ExperimentMoons),
"*",
}
client := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: dv,
},
})
_ = coderdtest.CreateFirstUser(t, client)
_ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureWorkspaceProxy: 1,
},
})
expectedName := "test-proxy"
ctx := testutil.Context(t, testutil.WaitLong)
inv, conf := newCLI(
t,
"proxy", "create",
"--name", expectedName,
"--display-name", "Test Proxy",
"--icon", "/emojis/1f4bb.png",
"--access-url", "http://localhost:3010",
"--only-token",
)
pty := ptytest.New(t)
inv.Stdout = pty.Output()
clitest.SetupConfig(t, client, conf)
err := inv.WithContext(ctx).Run()
require.NoError(t, err)
line := pty.ReadLine(ctx)
parts := strings.Split(line, ":")
require.Len(t, parts, 2, "expected 2 parts")
_, err = uuid.Parse(parts[0])
require.NoError(t, err, "expected token to be a uuid")
proxies, err := client.WorkspaceProxies(ctx)
require.NoError(t, err, "failed to get workspace proxies")
require.Len(t, proxies, 1, "expected 1 proxy")
require.Equal(t, expectedName, proxies[0].Name, "expected proxy name to match")
})
t.Run("Delete", func(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.Experiments = []string{
string(codersdk.ExperimentMoons),
"*",
}
client := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: dv,
},
})
_ = coderdtest.CreateFirstUser(t, client)
_ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureWorkspaceProxy: 1,
},
})
ctx := testutil.Context(t, testutil.WaitLong)
expectedName := "test-proxy"
_, err := client.CreateWorkspaceProxy(ctx, codersdk.CreateWorkspaceProxyRequest{
Name: expectedName,
DisplayName: "Test Proxy",
Icon: "/emojis/us.png",
URL: "http://localhost:3010",
})
require.NoError(t, err, "failed to create workspace proxy")
inv, conf := newCLI(
t,
"proxy", "delete", expectedName,
)
pty := ptytest.New(t)
inv.Stdout = pty.Output()
clitest.SetupConfig(t, client, conf)
err = inv.WithContext(ctx).Run()
require.NoError(t, err)
proxies, err := client.WorkspaceProxies(ctx)
require.NoError(t, err, "failed to get workspace proxies")
require.Len(t, proxies, 0, "expected no proxies")
})
}

View File

@ -100,15 +100,16 @@ func New(ctx context.Context, options *Options) (*API, error) {
}),
)
r.Post("/issue-signed-app-token", api.workspaceProxyIssueSignedAppToken)
r.Post("/register", api.workspaceProxyRegister)
})
r.Route("/{workspaceproxy}", func(r chi.Router) {
r.Use(
apiKeyMiddleware,
httpmw.ExtractWorkspaceProxyParam(api.Database),
)
r.Delete("/", api.deleteWorkspaceProxy)
})
// TODO: Add specific workspace proxy endpoints.
// r.Route("/{proxyName}", func(r chi.Router) {
// r.Use(
// httpmw.ExtractWorkspaceProxyByNameParam(api.Database),
// )
//
// r.Get("/", api.workspaceProxyByName)
// })
})
r.Route("/organizations/{organization}/groups", func(r chi.Router) {
r.Use(

View File

@ -115,14 +115,13 @@ func NewWorkspaceProxy(t *testing.T, coderdAPI *coderd.API, owner *codersdk.Clie
})
require.NoError(t, err, "failed to create workspace proxy")
wssrv, err := wsproxy.New(&wsproxy.Options{
wssrv, err := wsproxy.New(ctx, &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,

View File

@ -13,12 +13,55 @@ import (
"github.com/coder/coder/coderd/audit"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/httpmw"
"github.com/coder/coder/coderd/workspaceapps"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/cryptorand"
"github.com/coder/coder/enterprise/wsproxy/wsproxysdk"
)
// @Summary Delete workspace proxy
// @ID delete-workspace-proxy
// @Security CoderSessionToken
// @Produce json
// @Tags Enterprise
// @Param workspaceproxy path string true "Proxy ID or name" format(uuid)
// @Success 200 {object} codersdk.Response
// @Router /workspaceproxies/{workspaceproxy} [delete]
func (api *API) deleteWorkspaceProxy(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
proxy = httpmw.WorkspaceProxyParam(r)
auditor = api.AGPL.Auditor.Load()
aReq, commitAudit = audit.InitRequest[database.WorkspaceProxy](rw, &audit.RequestParams{
Audit: *auditor,
Log: api.Logger,
Request: r,
Action: database.AuditActionCreate,
})
)
aReq.Old = proxy
defer commitAudit()
err := api.Database.UpdateWorkspaceProxyDeleted(ctx, database.UpdateWorkspaceProxyDeletedParams{
ID: proxy.ID,
Deleted: true,
})
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
aReq.New = database.WorkspaceProxy{}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.Response{
Message: "Proxy has been deleted!",
})
}
// @Summary Create workspace proxy
// @ID create-workspace-proxy
// @Security CoderSessionToken
@ -208,3 +251,66 @@ func (api *API) workspaceProxyIssueSignedAppToken(rw http.ResponseWriter, r *htt
SignedTokenStr: tokenStr,
})
}
// workspaceProxyRegister is used to register a new workspace proxy. When a proxy
// comes online, it will announce itself to this endpoint. This updates its values
// in the database and returns a signed token that can be used to authenticate
// tokens.
//
// @Summary Register workspace proxy
// @ID register-workspace-proxy
// @Security CoderSessionToken
// @Accept json
// @Produce json
// @Tags Enterprise
// @Param request body wsproxysdk.RegisterWorkspaceProxyRequest true "Issue signed app token request"
// @Success 201 {object} wsproxysdk.RegisterWorkspaceProxyResponse
// @Router /workspaceproxies/me/register [post]
// @x-apidocgen {"skip": true}
func (api *API) workspaceProxyRegister(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
proxy = httpmw.WorkspaceProxy(r)
)
var req wsproxysdk.RegisterWorkspaceProxyRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
if err := validateProxyURL(req.AccessURL); err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "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
}
}
_, err := api.Database.RegisterWorkspaceProxy(ctx, database.RegisterWorkspaceProxyParams{
ID: proxy.ID,
Url: req.AccessURL,
WildcardHostname: req.WildcardHostname,
})
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
httpapi.Write(ctx, rw, http.StatusCreated, wsproxysdk.RegisterWorkspaceProxyResponse{
AppSecurityKey: api.AppSecurityKey.String(),
})
}

View File

@ -62,6 +62,42 @@ func TestWorkspaceProxyCRUD(t *testing.T) {
require.Equal(t, proxyRes.Proxy, proxies[0])
require.NotEmpty(t, proxyRes.ProxyToken)
})
t.Run("delete", func(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.Experiments = []string{
string(codersdk.ExperimentMoons),
"*",
}
client := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: dv,
},
})
_ = coderdtest.CreateFirstUser(t, client)
_ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureWorkspaceProxy: 1,
},
})
ctx := testutil.Context(t, testutil.WaitLong)
proxyRes, err := client.CreateWorkspaceProxy(ctx, codersdk.CreateWorkspaceProxyRequest{
Name: namesgenerator.GetRandomName(1),
Icon: "/emojis/flag.png",
URL: "https://" + namesgenerator.GetRandomName(1) + ".com",
WildcardHostname: "*.sub.example.com",
})
require.NoError(t, err)
err = client.DeleteWorkspaceProxyByID(ctx, proxyRes.Proxy.ID)
require.NoError(t, err, "failed to delete workspace proxy")
proxies, err := client.WorkspaceProxies(ctx)
require.NoError(t, err)
require.Len(t, proxies, 0)
})
}
func TestIssueSignedAppToken(t *testing.T) {

View File

@ -32,8 +32,7 @@ type Options struct {
// 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 is the URL of the WorkspaceProxy.
AccessURL *url.URL
// TODO: @emyrk We use these two fields in many places with this comment.
@ -49,9 +48,6 @@ type Options struct {
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
@ -72,7 +68,6 @@ func (o *Options) Validate() error {
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
@ -107,7 +102,10 @@ type Server struct {
cancel context.CancelFunc
}
func New(opts *Options) (*Server, error) {
// New creates a new workspace proxy server. This requires a primary coderd
// instance to be reachable and the correct authorization access token to be
// provided. If the proxy cannot authenticate with the primary, this will fail.
func New(ctx context.Context, opts *Options) (*Server, error) {
if opts.PrometheusRegistry == nil {
opts.PrometheusRegistry = prometheus.NewRegistry()
}
@ -116,13 +114,34 @@ func New(opts *Options) (*Server, error) {
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)
}
// TODO: Probably do some version checking here
info, err := client.SDKClient.BuildInfo(ctx)
if err != nil {
return nil, xerrors.Errorf("failed to fetch build info from %q: %w", opts.DashboardURL, err)
}
if info.WorkspaceProxy {
return nil, xerrors.Errorf("%q is a workspace proxy, not a primary coderd instance", opts.DashboardURL)
}
regResp, err := client.RegisterWorkspaceProxy(ctx, wsproxysdk.RegisterWorkspaceProxyRequest{
AccessURL: opts.AccessURL.String(),
WildcardHostname: opts.AppHostname,
})
if err != nil {
return nil, xerrors.Errorf("register proxy: %w", err)
}
secKey, err := workspaceapps.KeyFromString(regResp.AppSecurityKey)
if err != nil {
return nil, xerrors.Errorf("parse app security key: %w", err)
}
r := chi.NewRouter()
ctx, cancel := context.WithCancel(context.Background())
s := &Server{
@ -149,11 +168,11 @@ func New(opts *Options) (*Server, error) {
AccessURL: opts.AccessURL,
AppHostname: opts.AppHostname,
Client: client,
SecurityKey: s.Options.AppSecurityKey,
SecurityKey: secKey,
Logger: s.Logger.Named("proxy_token_provider"),
},
WorkspaceConnCache: wsconncache.New(s.DialWorkspaceAgent, 0),
AppSecurityKey: opts.AppSecurityKey,
AppSecurityKey: secKey,
DisablePathApps: opts.DisablePathApps,
SecureAuthCookie: opts.SecureAuthCookie,
@ -220,9 +239,10 @@ func (s *Server) DialWorkspaceAgent(id uuid.UUID) (*codersdk.WorkspaceAgentConn,
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(),
ExternalURL: buildinfo.ExternalURL(),
Version: buildinfo.Version(),
DashboardURL: s.DashboardURL.String(),
WorkspaceProxy: true,
})
}

View File

@ -142,3 +142,31 @@ func (c *Client) IssueSignedAppTokenHTML(ctx context.Context, rw http.ResponseWr
}
return res, true
}
type RegisterWorkspaceProxyRequest struct {
// AccessURL that hits the workspace proxy api.
AccessURL string `json:"access_url"`
// WildcardHostname that the workspace proxy api is serving for subdomain apps.
WildcardHostname string `json:"wildcard_hostname"`
}
type RegisterWorkspaceProxyResponse struct {
AppSecurityKey string `json:"app_security_key"`
}
func (c *Client) RegisterWorkspaceProxy(ctx context.Context, req RegisterWorkspaceProxyRequest) (RegisterWorkspaceProxyResponse, error) {
res, err := c.Request(ctx, http.MethodPost,
"/api/v2/workspaceproxies/me/register",
req,
)
if err != nil {
return RegisterWorkspaceProxyResponse{}, xerrors.Errorf("make request: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
return RegisterWorkspaceProxyResponse{}, codersdk.ReadBodyAsError(res)
}
var resp RegisterWorkspaceProxyResponse
return resp, json.NewDecoder(res.Body).Decode(&resp)
}

View File

@ -15,8 +15,9 @@ set -euo pipefail
DEFAULT_PASSWORD="SomeSecurePassword!"
password="${CODER_DEV_ADMIN_PASSWORD:-${DEFAULT_PASSWORD}}"
use_proxy=0
args="$(getopt -o "" -l agpl,password: -- "$@")"
args="$(getopt -o "" -l use-proxy,agpl,password: -- "$@")"
eval set -- "$args"
while true; do
case "$1" in
@ -28,6 +29,10 @@ while true; do
password="$2"
shift 2
;;
--use-proxy)
use_proxy=1
shift
;;
--)
shift
break
@ -38,6 +43,10 @@ while true; do
esac
done
if [ "${CODER_BUILD_AGPL:-0}" -gt "0" ] && [ "${use_proxy}" -gt "0" ]; then
echo '== ERROR: cannot use both external proxies and APGL build.' && exit 1
fi
# Preflight checks: ensure we have our required dependencies, and make sure nothing is listening on port 3000 or 8080
dependencies curl git go make yarn
curl --fail http://127.0.0.1:3000 >/dev/null 2>&1 && echo '== ERROR: something is listening on port 3000. Kill it and re-run this script.' && exit 1
@ -168,6 +177,18 @@ fatal() {
) || echo "Failed to create a template. The template files are in ${temp_template_dir}"
fi
if [ "${use_proxy}" -gt "0" ]; then
log "Using external workspace proxy"
(
# Attempt to delete the proxy first, in case it already exists.
"${CODER_DEV_SHIM}" proxy delete local-proxy || true
# Create the proxy
proxy_session_token=$("${CODER_DEV_SHIM}" proxy create --name=local-proxy --display-name="Local Proxy" --icon="/emojis/1f4bb.png" --access-url=http://localhost:3010 --only-token)
# Start the proxy
start_cmd PROXY "" "${CODER_DEV_SHIM}" proxy server --http-address=localhost:3010 --proxy-session-token="${proxy_session_token}" --primary-access-url=http://localhost:3000
) || echo "Failed to create workspace proxy. No workspace proxy created."
fi
# Start the frontend once we have a template up and running
CODER_HOST=http://127.0.0.1:3000 start_cmd SITE date yarn --cwd=./site dev --host
@ -192,6 +213,11 @@ fatal() {
for iface in "${interfaces[@]}"; do
log "$(printf "== Web UI: http://%s:8080%$((space_padding - ${#iface}))s==" "$iface" "")"
done
if [ "${use_proxy}" -gt "0" ]; then
for iface in "${interfaces[@]}"; do
log "$(printf "== Proxy: http://%s:3010%$((space_padding - ${#iface}))s==" "$iface" "")"
done
fi
log "== =="
log "== Use ./scripts/coder-dev.sh to talk to this instance! =="
log "$(printf "== alias cdr=%s/scripts/coder-dev.sh%$((space_padding - ${#PWD}))s==" "$PWD" "")"