diff --git a/cli/clibase/option.go b/cli/clibase/option.go index 076346e004ce7..390e9e073f2ca 100644 --- a/cli/clibase/option.go +++ b/cli/clibase/option.go @@ -80,6 +80,16 @@ func (s *OptionSet) Add(opts ...Option) { *s = append(*s, opts...) } +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 { diff --git a/cli/cliui/output.go b/cli/cliui/output.go index c9ed34677971c..b090d6795cdf5 100644 --- a/cli/cliui/output.go +++ b/cli/cliui/output.go @@ -192,3 +192,30 @@ func (textFormat) AttachOptions(_ *clibase.OptionSet) {} func (textFormat) Format(_ context.Context, data any) (string, error) { return fmt.Sprintf("%s", data), nil } + +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) +} diff --git a/cli/server.go b/cli/server.go index a3b19b88e5788..02ec852d95681 100644 --- a/cli/server.go +++ b/cli/server.go @@ -163,6 +163,19 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. var ( cfg = new(codersdk.DeploymentValues) opts = cfg.Options() + // For the develop.sh script, it is helpful to make this key deterministic. + devAppSecurityKey string + ) + opts.Add( + clibase.Option{ + Name: "App Security Key (Development Only)", + Description: "Used to override the app security key stored in the database. This should never be used in production.", + Flag: "dangerous-dev-app-security-key", + Default: "", + Value: clibase.StringOf(&devAppSecurityKey), + Annotations: clibase.Annotations{}.Mark("secret", "true"), + Hidden: true, + }, ) serverCmd := &clibase.Cmd{ Use: "server", @@ -621,6 +634,17 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. } } + if devAppSecurityKey != "" { + _, err := workspaceapps.KeyFromString(devAppSecurityKey) + if err != nil { + return xerrors.Errorf("invalid dev app security key: %w", err) + } + err = tx.UpsertAppSecurityKey(ctx, devAppSecurityKey) + if err != nil { + return xerrors.Errorf("Insert dev app security key: %w", err) + } + } + // Read the app signing key from the DB. We store it hex encoded // since the config table uses strings for the value and we // don't want to deal with automatic encoding issues. diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 135cdf1f7e9b6..ac7207ca474af 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -5067,6 +5067,44 @@ const docTemplate = `{ } } }, + "/workspaceproxies/{workspaceproxy}": { + "delete": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": [ + "application/json" + ], + "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": [ @@ -6371,12 +6409,12 @@ const docTemplate = `{ "description": "ExternalURL references the current Coder version.\nFor production builds, this will link directly to a release. For development builds, this will link to a commit.", "type": "string" }, + "is_workspace_proxy": { + "type": "boolean" + }, "version": { "description": "Version returns the semantic version of the build.", "type": "string" - }, - "workspace_proxy": { - "type": "boolean" } } }, @@ -7214,9 +7252,11 @@ const docTemplate = `{ "codersdk.Experiment": { "type": "string", "enum": [ + "template_editor", "moons" ], "x-enum-varnames": [ + "ExperimentTemplateEditor", "ExperimentMoons" ] }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 31acf01b313b3..fbd9e852c19fd 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -4459,6 +4459,38 @@ } } }, + "/workspaceproxies/{workspaceproxy}": { + "delete": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": ["application/json"], + "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": [ @@ -5683,12 +5715,12 @@ "description": "ExternalURL references the current Coder version.\nFor production builds, this will link directly to a release. For development builds, this will link to a commit.", "type": "string" }, + "is_workspace_proxy": { + "type": "boolean" + }, "version": { "description": "Version returns the semantic version of the build.", "type": "string" - }, - "workspace_proxy": { - "type": "boolean" } } }, @@ -6463,8 +6495,8 @@ }, "codersdk.Experiment": { "type": "string", - "enum": ["moons"], - "x-enum-varnames": ["ExperimentMoons"] + "enum": ["template_editor", "moons"], + "x-enum-varnames": ["ExperimentTemplateEditor", "ExperimentMoons"] }, "codersdk.Feature": { "type": "object", diff --git a/coderd/database/dbauthz/querier.go b/coderd/database/dbauthz/querier.go index 2b9810405e5a9..d3a86da0c02dc 100644 --- a/coderd/database/dbauthz/querier.go +++ b/coderd/database/dbauthz/querier.go @@ -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) } diff --git a/coderd/database/dbfake/databasefake.go b/coderd/database/dbfake/databasefake.go index 9d37f195dd01b..f7995ad10ab64 100644 --- a/coderd/database/dbfake/databasefake.go +++ b/coderd/database/dbfake/databasefake.go @@ -5097,6 +5097,18 @@ 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.Name == name { + return proxy, nil + } + } + return database.WorkspaceProxy{}, sql.ErrNoRows +} + func (q *fakeQuerier) GetWorkspaceProxyByHostname(_ context.Context, hostname string) (database.WorkspaceProxy, error) { q.mutex.Lock() defer q.mutex.Unlock() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 7feb2e8b78b88..47e574ef52e04 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -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) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 5dd8577d3c18a..fdf2fe2020fea 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -2937,6 +2937,35 @@ 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 +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 ( diff --git a/coderd/database/queries/proxies.sql b/coderd/database/queries/proxies.sql index 807105238bc93..d384b0daebb71 100644 --- a/coderd/database/queries/proxies.sql +++ b/coderd/database/queries/proxies.sql @@ -49,6 +49,16 @@ WHERE LIMIT 1; +-- name: GetWorkspaceProxyByName :one +SELECT + * +FROM + workspace_proxies +WHERE + name = $1 +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. diff --git a/coderd/httpmw/workspaceproxy.go b/coderd/httpmw/workspaceproxy.go index 28961ea19c08b..692f51b83d2b4 100644 --- a/coderd/httpmw/workspaceproxy.go +++ b/coderd/httpmw/workspaceproxy.go @@ -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)) + }) + } +} diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 2e153d02e462a..b0623a56729ca 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -333,10 +333,17 @@ type DangerousConfig struct { } const ( - flagEnterpriseKey = "enterprise" - flagSecretKey = "secret" + flagEnterpriseKey = "enterprise" + flagSecretKey = "secret" + flagExternalProxies = "external_workspace_proxies" ) +func IsExternalProxies(opt clibase.Option) bool { + // If it is a bool, use the bool value. + b, _ := strconv.ParseBool(opt.Annotations[flagExternalProxies]) + return b +} + func IsSecretDeploymentOption(opt clibase.Option) bool { return opt.Annotations.IsSet(flagSecretKey) } @@ -470,6 +477,7 @@ when required by your organization's security policy.`, Value: &c.HTTPAddress, Group: &deploymentGroupNetworkingHTTP, YAML: "httpAddress", + Annotations: clibase.Annotations{}.Mark(flagExternalProxies, "true"), } tlsBindAddress := clibase.Option{ Name: "TLS Address", @@ -480,6 +488,7 @@ when required by your organization's security policy.`, Value: &c.TLS.Address, Group: &deploymentGroupNetworkingTLS, YAML: "address", + Annotations: clibase.Annotations{}.Mark(flagExternalProxies, "true"), } redirectToAccessURL := clibase.Option{ Name: "Redirect to Access URL", @@ -499,6 +508,7 @@ when required by your organization's security policy.`, Env: "CODER_ACCESS_URL", Group: &deploymentGroupNetworking, YAML: "accessURL", + Annotations: clibase.Annotations{}.Mark(flagExternalProxies, "true"), }, { Name: "Wildcard Access URL", @@ -508,6 +518,7 @@ when required by your organization's security policy.`, Value: &c.WildcardAccessURL, Group: &deploymentGroupNetworking, YAML: "wildcardAccessURL", + Annotations: clibase.Annotations{}.Mark(flagExternalProxies, "true"), }, redirectToAccessURL, { @@ -534,7 +545,8 @@ when required by your organization's security policy.`, httpAddress, tlsBindAddress, }, - Group: &deploymentGroupNetworking, + Group: &deploymentGroupNetworking, + Annotations: clibase.Annotations{}.Mark(flagExternalProxies, "true"), }, // TLS settings { @@ -545,6 +557,7 @@ when required by your organization's security policy.`, Value: &c.TLS.Enable, Group: &deploymentGroupNetworkingTLS, YAML: "enable", + Annotations: clibase.Annotations{}.Mark(flagExternalProxies, "true"), }, { Name: "Redirect HTTP to HTTPS", @@ -557,6 +570,7 @@ when required by your organization's security policy.`, UseInstead: clibase.OptionSet{redirectToAccessURL}, Group: &deploymentGroupNetworkingTLS, YAML: "redirectHTTP", + Annotations: clibase.Annotations{}.Mark(flagExternalProxies, "true"), }, { Name: "TLS Certificate Files", @@ -566,6 +580,7 @@ when required by your organization's security policy.`, Value: &c.TLS.CertFiles, Group: &deploymentGroupNetworkingTLS, YAML: "certFiles", + Annotations: clibase.Annotations{}.Mark(flagExternalProxies, "true"), }, { Name: "TLS Client CA Files", @@ -575,6 +590,7 @@ when required by your organization's security policy.`, Value: &c.TLS.ClientCAFile, Group: &deploymentGroupNetworkingTLS, YAML: "clientCAFile", + Annotations: clibase.Annotations{}.Mark(flagExternalProxies, "true"), }, { Name: "TLS Client Auth", @@ -585,6 +601,7 @@ when required by your organization's security policy.`, Value: &c.TLS.ClientAuth, Group: &deploymentGroupNetworkingTLS, YAML: "clientAuth", + Annotations: clibase.Annotations{}.Mark(flagExternalProxies, "true"), }, { Name: "TLS Key Files", @@ -594,6 +611,7 @@ when required by your organization's security policy.`, Value: &c.TLS.KeyFiles, Group: &deploymentGroupNetworkingTLS, YAML: "keyFiles", + Annotations: clibase.Annotations{}.Mark(flagExternalProxies, "true"), }, { Name: "TLS Minimum Version", @@ -604,6 +622,7 @@ when required by your organization's security policy.`, Value: &c.TLS.MinVersion, Group: &deploymentGroupNetworkingTLS, YAML: "minVersion", + Annotations: clibase.Annotations{}.Mark(flagExternalProxies, "true"), }, { Name: "TLS Client Cert File", @@ -613,6 +632,7 @@ when required by your organization's security policy.`, Value: &c.TLS.ClientCertFile, Group: &deploymentGroupNetworkingTLS, YAML: "clientCertFile", + Annotations: clibase.Annotations{}.Mark(flagExternalProxies, "true"), }, { Name: "TLS Client Key File", @@ -622,6 +642,7 @@ when required by your organization's security policy.`, Value: &c.TLS.ClientKeyFile, Group: &deploymentGroupNetworkingTLS, YAML: "clientKeyFile", + Annotations: clibase.Annotations{}.Mark(flagExternalProxies, "true"), }, // Derp settings { @@ -712,6 +733,7 @@ when required by your organization's security policy.`, Value: &c.Prometheus.Enable, Group: &deploymentGroupIntrospectionPrometheus, YAML: "enable", + Annotations: clibase.Annotations{}.Mark(flagExternalProxies, "true"), }, { Name: "Prometheus Address", @@ -722,6 +744,7 @@ when required by your organization's security policy.`, Value: &c.Prometheus.Address, Group: &deploymentGroupIntrospectionPrometheus, YAML: "address", + Annotations: clibase.Annotations{}.Mark(flagExternalProxies, "true"), }, { Name: "Prometheus Collect Agent Stats", @@ -741,6 +764,7 @@ when required by your organization's security policy.`, Value: &c.Pprof.Enable, Group: &deploymentGroupIntrospectionPPROF, YAML: "enable", + Annotations: clibase.Annotations{}.Mark(flagExternalProxies, "true"), }, { Name: "pprof Address", @@ -751,6 +775,7 @@ when required by your organization's security policy.`, Value: &c.Pprof.Address, Group: &deploymentGroupIntrospectionPPROF, YAML: "address", + Annotations: clibase.Annotations{}.Mark(flagExternalProxies, "true"), }, // oAuth settings { @@ -1007,13 +1032,14 @@ when required by your organization's security policy.`, Value: &c.Trace.Enable, Group: &deploymentGroupIntrospectionTracing, YAML: "enable", + Annotations: clibase.Annotations{}.Mark(flagExternalProxies, "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(flagSecretKey, "true").Mark(flagExternalProxies, "true"), Value: &c.Trace.HoneycombAPIKey, Group: &deploymentGroupIntrospectionTracing, }, @@ -1025,6 +1051,7 @@ when required by your organization's security policy.`, Value: &c.Trace.CaptureLogs, Group: &deploymentGroupIntrospectionTracing, YAML: "captureLogs", + Annotations: clibase.Annotations{}.Mark(flagExternalProxies, "true"), }, // Provisioner settings { @@ -1074,19 +1101,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(flagExternalProxies, "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(flagExternalProxies, "true"), }, // Logging settings { @@ -1096,9 +1125,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(flagExternalProxies, "true"), }, { Name: "Human Log Location", @@ -1109,6 +1139,7 @@ when required by your organization's security policy.`, Value: &c.Logging.Human, Group: &deploymentGroupIntrospectionLogging, YAML: "humanPath", + Annotations: clibase.Annotations{}.Mark(flagExternalProxies, "true"), }, { Name: "JSON Log Location", @@ -1119,6 +1150,7 @@ when required by your organization's security policy.`, Value: &c.Logging.JSON, Group: &deploymentGroupIntrospectionLogging, YAML: "jsonPath", + Annotations: clibase.Annotations{}.Mark(flagExternalProxies, "true"), }, { Name: "Stackdriver Log Location", @@ -1129,6 +1161,7 @@ when required by your organization's security policy.`, Value: &c.Logging.Stackdriver, Group: &deploymentGroupIntrospectionLogging, YAML: "stackdriverPath", + Annotations: clibase.Annotations{}.Mark(flagExternalProxies, "true"), }, // ☢️ Dangerous settings { @@ -1157,6 +1190,7 @@ when required by your organization's security policy.`, Env: "CODER_EXPERIMENTS", Value: &c.Experiments, YAML: "experiments", + Annotations: clibase.Annotations{}.Mark(flagExternalProxies, "true"), }, { Name: "Update Check", @@ -1199,6 +1233,7 @@ when required by your organization's security policy.`, Value: &c.ProxyTrustedHeaders, Group: &deploymentGroupNetworking, YAML: "proxyTrustedHeaders", + Annotations: clibase.Annotations{}.Mark(flagExternalProxies, "true"), }, { Name: "Proxy Trusted Origins", @@ -1208,6 +1243,7 @@ when required by your organization's security policy.`, Value: &c.ProxyTrustedOrigins, Group: &deploymentGroupNetworking, YAML: "proxyTrustedOrigins", + Annotations: clibase.Annotations{}.Mark(flagExternalProxies, "true"), }, { Name: "Cache Directory", @@ -1243,28 +1279,31 @@ when required by your organization's security policy.`, Value: &c.SecureAuthCookie, Group: &deploymentGroupNetworking, YAML: "secureAuthCookie", + Annotations: clibase.Annotations{}.Mark(flagExternalProxies, "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(flagExternalProxies, "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(flagExternalProxies, "true"), }, { Name: "SSH Keygen Algorithm", @@ -1308,7 +1347,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(flagEnterpriseKey, "true").Mark(flagExternalProxies, "true"), Value: &c.BrowserOnly, Group: &deploymentGroupNetworking, YAML: "browserOnly", @@ -1328,8 +1367,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(flagExternalProxies, "true"), }, { Name: "Disable Owner Workspace Access", @@ -1337,8 +1377,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(flagExternalProxies, "true"), }, { Name: "Session Duration", diff --git a/codersdk/workspaceproxy.go b/codersdk/workspaceproxy.go index 675eecd65217b..9b3521bcb4098 100644 --- a/codersdk/workspaceproxy.go +++ b/codersdk/workspaceproxy.go @@ -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()) +} diff --git a/docs/api/enterprise.md b/docs/api/enterprise.md index f82e4f153b75a..a6bc536bb27e6 100644 --- a/docs/api/enterprise.md +++ b/docs/api/enterprise.md @@ -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). diff --git a/enterprise/cli/proxyserver.go b/enterprise/cli/proxyserver.go new file mode 100644 index 0000000000000..3340d53241555 --- /dev/null +++ b/enterprise/cli/proxyserver.go @@ -0,0 +1,361 @@ +//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/coderd/workspaceapps" + "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 (r *RootCmd) proxyServer() *clibase.Cmd { + var ( + cfg = new(codersdk.DeploymentValues) + // Filter options for only relevant ones. + opts = cfg.Options().Filter(codersdk.IsExternalProxies) + + externalProxyOptionGroup = clibase.Group{ + Name: "External Workspace Proxy", + YAML: "externalWorkspaceProxy", + } + proxySessionToken clibase.String + primaryAccessURL clibase.URL + appSecuritYKey clibase.String + ) + 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, + }, + + // TODO: Make sure this is kept secret. Idk if a flag is the best option + clibase.Option{ + Name: "App Security Key", + Description: "App security key used for decrypting/verifying app tokens sent from coderd.", + Flag: "app-security-key", + Env: "CODER_APP_SECURITY_KEY", + YAML: "appSecurityKey", + Default: "", + Value: &appSecuritYKey, + Group: &externalProxyOptionGroup, + Hidden: false, + Annotations: clibase.Annotations{}.Mark("secret", "true"), + }, + ) + + client := new(codersdk.Client) + cmd := &clibase.Cmd{ + Use: "server", + Short: "Start a workspace proxy server", + Options: opts, + Middleware: clibase.Chain( + cli.WriteConfigMW(cfg), + cli.PrintDeprecatedOptions(), + clibase.RequireNArgs(0), + // We need a client to connect with the primary coderd instance. + r.InitClient(client), + ), + 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()) + } + + secKey, err := workspaceapps.KeyFromString(appSecuritYKey.Value()) + if err != nil { + return xerrors.Errorf("app security key: %w", err) + } + + 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) + + // 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(&wsproxy.Options{ + Logger: logger, + DashboardURL: primaryAccessURL.Value(), + AccessURL: cfg.AccessURL.Value(), + AppHostname: appHostname, + AppHostnameRegex: appHostnameRegex, + RealIPConfig: realIPConfig, + AppSecurityKey: secKey, + 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 +} diff --git a/enterprise/cli/proxyserver_slim.go b/enterprise/cli/proxyserver_slim.go new file mode 100644 index 0000000000000..d484c43bde298 --- /dev/null +++ b/enterprise/cli/proxyserver_slim.go @@ -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) +} diff --git a/enterprise/cli/root.go b/enterprise/cli/root.go index b70c91ead5728..a89a8008fc977 100644 --- a/enterprise/cli/root.go +++ b/enterprise/cli/root.go @@ -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(), diff --git a/enterprise/cli/workspaceproxy.go b/enterprise/cli/workspaceproxy.go new file mode 100644 index 0000000000000..c62d4420086e4 --- /dev/null +++ b/enterprise/cli/workspaceproxy.go @@ -0,0 +1,155 @@ +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", + 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(), + 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: "register", + Short: "Register 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 +} diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 0a79176ba7cda..5baef82e19b8c 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -102,13 +102,13 @@ func New(ctx context.Context, options *Options) (*API, error) { r.Post("/issue-signed-app-token", api.workspaceProxyIssueSignedAppToken) }) // 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("/{workspaceproxy}", func(r chi.Router) { + r.Use( + httpmw.ExtractWorkspaceProxyParam(api.Database), + ) + + r.Delete("/", api.deleteWorkspaceProxy) + }) }) r.Route("/organizations/{organization}/groups", func(r chi.Router) { r.Use( diff --git a/enterprise/coderd/workspaceproxy.go b/enterprise/coderd/workspaceproxy.go index 65499d3167f69..6d679e0391d67 100644 --- a/enterprise/coderd/workspaceproxy.go +++ b/enterprise/coderd/workspaceproxy.go @@ -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 diff --git a/scripts/develop.sh b/scripts/develop.sh index cfeed0b76fbe7..d79b1af9d453b 100755 --- a/scripts/develop.sh +++ b/scripts/develop.sh @@ -15,8 +15,11 @@ set -euo pipefail DEFAULT_PASSWORD="SomeSecurePassword!" password="${CODER_DEV_ADMIN_PASSWORD:-${DEFAULT_PASSWORD}}" +use_proxy=0 +# Hard coded app security key for the proxy to also use. +app_security_key=bea458bcfb31990fb70f14a7003c0aad4f371f19653f3a632bc9d3492004cd95e0e542c30e5f158f26728373190bfc802b1c1549f52c149af8ad7f5b12ea1ee0995b95de3b86ae78f021c17437649224cd2dcf1b298d180811cf36f4dcb8f33e -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 +31,10 @@ while true; do password="$2" shift 2 ;; + --use-proxy) + use_proxy=1 + shift + ;; --) shift break @@ -38,6 +45,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 @@ -122,7 +133,7 @@ fatal() { trap 'fatal "Script encountered an error"' ERR cdroot - start_cmd API "" "${CODER_DEV_SHIM}" server --http-address 0.0.0.0:3000 --swagger-enable --access-url "http://127.0.0.1:3000" --experiments "*" "$@" + start_cmd API "" "${CODER_DEV_SHIM}" server --http-address 0.0.0.0:3000 --swagger-enable --access-url "http://127.0.0.1:3000" --dangerous-dev-app-security-key ${app_security_key} --experiments "*" "$@" echo '== Waiting for Coder to become ready' # Start the timeout in the background so interrupting this script @@ -168,6 +179,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 name=local-proxy || true + # Create the proxy + proxy_session_token=$("${CODER_DEV_SHIM}" proxy register --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 --app-security-key="${app_security_key}" + ) || 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 +215,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" "")" diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 6917b89456bab..2580c165230d1 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1,5 +1,3 @@ -// Code generated by 'make site/src/api/typesGenerated.ts'. DO NOT EDIT. - // From codersdk/apikey.go export interface APIKey { readonly id: string