mirror of
https://github.com/coder/coder.git
synced 2025-07-08 11:39:50 +00:00
Just moved `rbac.Action` -> `policy.Action`. This is for the stacked PR to not have circular dependencies when doing autogen. Without this, the autogen can produce broken golang code, which prevents the autogen from compiling. So just avoiding circular dependencies. Doing this in it's own PR to reduce LoC diffs in the primary PR, since this has 0 functional changes.
970 lines
30 KiB
Go
970 lines
30 KiB
Go
package coderd
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"golang.org/x/xerrors"
|
|
|
|
"cdr.dev/slog"
|
|
agpl "github.com/coder/coder/v2/coderd"
|
|
"github.com/coder/coder/v2/coderd/audit"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
|
"github.com/coder/coder/v2/coderd/database/dbtime"
|
|
"github.com/coder/coder/v2/coderd/httpapi"
|
|
"github.com/coder/coder/v2/coderd/httpmw"
|
|
"github.com/coder/coder/v2/coderd/rbac/policy"
|
|
"github.com/coder/coder/v2/coderd/telemetry"
|
|
"github.com/coder/coder/v2/coderd/workspaceapps"
|
|
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/cryptorand"
|
|
"github.com/coder/coder/v2/enterprise/coderd/proxyhealth"
|
|
"github.com/coder/coder/v2/enterprise/replicasync"
|
|
"github.com/coder/coder/v2/enterprise/wsproxy/wsproxysdk"
|
|
)
|
|
|
|
// forceWorkspaceProxyHealthUpdate forces an update of the proxy health.
|
|
// This is useful when a proxy is created or deleted. Errors will be logged.
|
|
func (api *API) forceWorkspaceProxyHealthUpdate(ctx context.Context) {
|
|
if err := api.ProxyHealth.ForceUpdate(ctx); err != nil && !database.IsQueryCanceledError(err) && !xerrors.Is(err, context.Canceled) {
|
|
api.Logger.Error(ctx, "force proxy health update", slog.Error(err))
|
|
}
|
|
}
|
|
|
|
// NOTE: this doesn't need a swagger definition since AGPL already has one, and
|
|
// this route overrides the AGPL one.
|
|
func (api *API) regions(rw http.ResponseWriter, r *http.Request) {
|
|
regions, err := api.fetchRegions(r.Context())
|
|
if err != nil {
|
|
httpapi.InternalServerError(rw, err)
|
|
return
|
|
}
|
|
|
|
httpapi.Write(r.Context(), rw, http.StatusOK, regions)
|
|
}
|
|
|
|
func (api *API) fetchRegions(ctx context.Context) (codersdk.RegionsResponse[codersdk.Region], error) {
|
|
//nolint:gocritic // this intentionally requests resources that users
|
|
// cannot usually access in order to give them a full list of available
|
|
// regions. Regions are just a data subset of proxies.
|
|
ctx = dbauthz.AsSystemRestricted(ctx)
|
|
proxies, err := api.fetchWorkspaceProxies(ctx)
|
|
if err != nil {
|
|
return codersdk.RegionsResponse[codersdk.Region]{}, err
|
|
}
|
|
|
|
regions := make([]codersdk.Region, 0, len(proxies.Regions))
|
|
for i := range proxies.Regions {
|
|
// Ignore deleted and DERP-only proxies.
|
|
if proxies.Regions[i].Deleted || proxies.Regions[i].DerpOnly {
|
|
continue
|
|
}
|
|
// Append the inner region data.
|
|
regions = append(regions, proxies.Regions[i].Region)
|
|
}
|
|
|
|
return codersdk.RegionsResponse[codersdk.Region]{
|
|
Regions: regions,
|
|
}, nil
|
|
}
|
|
|
|
// @Summary Update workspace proxy
|
|
// @ID update-workspace-proxy
|
|
// @Security CoderSessionToken
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Tags Enterprise
|
|
// @Param workspaceproxy path string true "Proxy ID or name" format(uuid)
|
|
// @Param request body codersdk.PatchWorkspaceProxy true "Update workspace proxy request"
|
|
// @Success 200 {object} codersdk.WorkspaceProxy
|
|
// @Router /workspaceproxies/{workspaceproxy} [patch]
|
|
func (api *API) patchWorkspaceProxy(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.AuditActionWrite,
|
|
})
|
|
)
|
|
aReq.Old = proxy
|
|
defer commitAudit()
|
|
|
|
var req codersdk.PatchWorkspaceProxy
|
|
if !httpapi.Read(ctx, rw, r, &req) {
|
|
return
|
|
}
|
|
|
|
var hashedSecret []byte
|
|
var fullToken string
|
|
if req.RegenerateToken {
|
|
var err error
|
|
fullToken, hashedSecret, err = generateWorkspaceProxyToken(proxy.ID)
|
|
if err != nil {
|
|
httpapi.InternalServerError(rw, err)
|
|
return
|
|
}
|
|
}
|
|
|
|
deploymentIDStr, err := api.Database.GetDeploymentID(ctx)
|
|
if err != nil {
|
|
httpapi.InternalServerError(rw, err)
|
|
return
|
|
}
|
|
|
|
var updatedProxy database.WorkspaceProxy
|
|
if proxy.ID.String() == deploymentIDStr {
|
|
// User is editing the default primary proxy.
|
|
var ok bool
|
|
updatedProxy, ok = api.patchPrimaryWorkspaceProxy(req, rw, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
} else {
|
|
updatedProxy, err = api.Database.UpdateWorkspaceProxy(ctx, database.UpdateWorkspaceProxyParams{
|
|
Name: req.Name,
|
|
DisplayName: req.DisplayName,
|
|
Icon: req.Icon,
|
|
ID: proxy.ID,
|
|
// If hashedSecret is nil or empty, this will not update the secret.
|
|
TokenHashedSecret: hashedSecret,
|
|
})
|
|
if httpapi.Is404Error(err) {
|
|
httpapi.ResourceNotFound(rw)
|
|
return
|
|
}
|
|
if err != nil {
|
|
httpapi.InternalServerError(rw, err)
|
|
return
|
|
}
|
|
}
|
|
|
|
aReq.New = updatedProxy
|
|
status, ok := api.ProxyHealth.HealthStatus()[updatedProxy.ID]
|
|
if !ok {
|
|
// The proxy should have some status, but just in case.
|
|
status.Status = proxyhealth.Unknown
|
|
}
|
|
httpapi.Write(ctx, rw, http.StatusOK, codersdk.UpdateWorkspaceProxyResponse{
|
|
Proxy: convertProxy(updatedProxy, status),
|
|
ProxyToken: fullToken,
|
|
})
|
|
|
|
// Update the proxy cache.
|
|
go api.forceWorkspaceProxyHealthUpdate(api.ctx)
|
|
}
|
|
|
|
// patchPrimaryWorkspaceProxy handles the special case of updating the default
|
|
func (api *API) patchPrimaryWorkspaceProxy(req codersdk.PatchWorkspaceProxy, rw http.ResponseWriter, r *http.Request) (database.WorkspaceProxy, bool) {
|
|
var (
|
|
ctx = r.Context()
|
|
proxy = httpmw.WorkspaceProxyParam(r)
|
|
)
|
|
|
|
// User is editing the default primary proxy.
|
|
if req.Name != "" && req.Name != proxy.Name {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Cannot update name of default primary proxy, did you mean to update the 'display name'?",
|
|
Validations: []codersdk.ValidationError{
|
|
{Field: "name", Detail: "Cannot update name of default primary proxy"},
|
|
},
|
|
})
|
|
return database.WorkspaceProxy{}, false
|
|
}
|
|
if req.DisplayName == "" && req.Icon == "" {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "No update arguments provided. Nothing to do.",
|
|
Validations: []codersdk.ValidationError{
|
|
{Field: "display_name", Detail: "No value provided."},
|
|
{Field: "icon", Detail: "No value provided."},
|
|
},
|
|
})
|
|
return database.WorkspaceProxy{}, false
|
|
}
|
|
|
|
args := database.UpsertDefaultProxyParams{
|
|
DisplayName: req.DisplayName,
|
|
IconUrl: req.Icon,
|
|
}
|
|
if req.DisplayName == "" || req.Icon == "" {
|
|
// If the user has not specified an update value, use the existing value.
|
|
existing, err := api.Database.GetDefaultProxyConfig(ctx)
|
|
if err != nil {
|
|
httpapi.InternalServerError(rw, err)
|
|
return database.WorkspaceProxy{}, false
|
|
}
|
|
if req.DisplayName == "" {
|
|
args.DisplayName = existing.DisplayName
|
|
}
|
|
if req.Icon == "" {
|
|
args.IconUrl = existing.IconUrl
|
|
}
|
|
}
|
|
|
|
err := api.Database.UpsertDefaultProxy(ctx, args)
|
|
if err != nil {
|
|
httpapi.InternalServerError(rw, err)
|
|
return database.WorkspaceProxy{}, false
|
|
}
|
|
|
|
// Use the primary region to fetch the default proxy values.
|
|
updatedProxy, err := api.AGPL.PrimaryWorkspaceProxy(ctx)
|
|
if err != nil {
|
|
httpapi.InternalServerError(rw, err)
|
|
return database.WorkspaceProxy{}, false
|
|
}
|
|
return updatedProxy, true
|
|
}
|
|
|
|
// @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.AuditActionDelete,
|
|
})
|
|
)
|
|
aReq.Old = proxy
|
|
defer commitAudit()
|
|
|
|
if proxy.IsPrimary() {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Cannot delete primary proxy",
|
|
})
|
|
return
|
|
}
|
|
|
|
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!",
|
|
})
|
|
|
|
// Update the proxy health cache to remove this proxy.
|
|
go api.forceWorkspaceProxyHealthUpdate(api.ctx)
|
|
}
|
|
|
|
// @Summary Get workspace proxy
|
|
// @ID get-workspace-proxy
|
|
// @Security CoderSessionToken
|
|
// @Produce json
|
|
// @Tags Enterprise
|
|
// @Param workspaceproxy path string true "Proxy ID or name" format(uuid)
|
|
// @Success 200 {object} codersdk.WorkspaceProxy
|
|
// @Router /workspaceproxies/{workspaceproxy} [get]
|
|
func (api *API) workspaceProxy(rw http.ResponseWriter, r *http.Request) {
|
|
var (
|
|
ctx = r.Context()
|
|
proxy = httpmw.WorkspaceProxyParam(r)
|
|
)
|
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, convertProxy(proxy, api.ProxyHealth.HealthStatus()[proxy.ID]))
|
|
}
|
|
|
|
// @Summary Create workspace proxy
|
|
// @ID create-workspace-proxy
|
|
// @Security CoderSessionToken
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Tags Enterprise
|
|
// @Param request body codersdk.CreateWorkspaceProxyRequest true "Create workspace proxy request"
|
|
// @Success 201 {object} codersdk.WorkspaceProxy
|
|
// @Router /workspaceproxies [post]
|
|
func (api *API) postWorkspaceProxy(rw http.ResponseWriter, r *http.Request) {
|
|
var (
|
|
ctx = r.Context()
|
|
auditor = api.AGPL.Auditor.Load()
|
|
aReq, commitAudit = audit.InitRequest[database.WorkspaceProxy](rw, &audit.RequestParams{
|
|
Audit: *auditor,
|
|
Log: api.Logger,
|
|
Request: r,
|
|
Action: database.AuditActionCreate,
|
|
})
|
|
)
|
|
defer commitAudit()
|
|
|
|
var req codersdk.CreateWorkspaceProxyRequest
|
|
if !httpapi.Read(ctx, rw, r, &req) {
|
|
return
|
|
}
|
|
|
|
if strings.ToLower(req.Name) == "primary" {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: `The name "primary" is reserved for the primary region.`,
|
|
Detail: "Cannot name a workspace proxy 'primary'.",
|
|
Validations: []codersdk.ValidationError{
|
|
{
|
|
Field: "name",
|
|
Detail: "Reserved name",
|
|
},
|
|
},
|
|
})
|
|
return
|
|
}
|
|
|
|
id := uuid.New()
|
|
fullToken, hashedSecret, err := generateWorkspaceProxyToken(id)
|
|
if err != nil {
|
|
httpapi.InternalServerError(rw, err)
|
|
return
|
|
}
|
|
|
|
proxy, err := api.Database.InsertWorkspaceProxy(ctx, database.InsertWorkspaceProxyParams{
|
|
ID: id,
|
|
Name: req.Name,
|
|
DisplayName: req.DisplayName,
|
|
Icon: req.Icon,
|
|
TokenHashedSecret: hashedSecret[:],
|
|
// Enabled by default, but will be disabled on register if the proxy has
|
|
// it disabled.
|
|
DerpEnabled: true,
|
|
// Disabled by default, but blah blah blah.
|
|
DerpOnly: false,
|
|
CreatedAt: dbtime.Now(),
|
|
UpdatedAt: dbtime.Now(),
|
|
})
|
|
if database.IsUniqueViolation(err, database.UniqueWorkspaceProxiesLowerNameIndex) {
|
|
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
|
|
Message: fmt.Sprintf("Workspace proxy with name %q already exists.", req.Name),
|
|
})
|
|
return
|
|
}
|
|
if err != nil {
|
|
httpapi.InternalServerError(rw, err)
|
|
return
|
|
}
|
|
|
|
api.Telemetry.Report(&telemetry.Snapshot{
|
|
WorkspaceProxies: []telemetry.WorkspaceProxy{telemetry.ConvertWorkspaceProxy(proxy)},
|
|
})
|
|
|
|
aReq.New = proxy
|
|
httpapi.Write(ctx, rw, http.StatusCreated, codersdk.UpdateWorkspaceProxyResponse{
|
|
Proxy: convertProxy(proxy, proxyhealth.ProxyStatus{
|
|
Proxy: proxy,
|
|
CheckedAt: time.Now(),
|
|
Status: proxyhealth.Unregistered,
|
|
}),
|
|
ProxyToken: fullToken,
|
|
})
|
|
|
|
// Update the proxy health cache to include this new proxy.
|
|
go api.forceWorkspaceProxyHealthUpdate(api.ctx)
|
|
}
|
|
|
|
// nolint:revive
|
|
func validateProxyURL(u string) error {
|
|
p, err := url.Parse(u)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if p.Scheme != "http" && p.Scheme != "https" {
|
|
return xerrors.New("scheme must be http or https")
|
|
}
|
|
if !(p.Path == "/" || p.Path == "") {
|
|
return xerrors.New("path must be empty or /")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// @Summary Get workspace proxies
|
|
// @ID get-workspace-proxies
|
|
// @Security CoderSessionToken
|
|
// @Produce json
|
|
// @Tags Enterprise
|
|
// @Success 200 {array} codersdk.RegionsResponse[codersdk.WorkspaceProxy]
|
|
// @Router /workspaceproxies [get]
|
|
func (api *API) workspaceProxies(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
proxies, err := api.fetchWorkspaceProxies(r.Context())
|
|
if err != nil {
|
|
if dbauthz.IsNotAuthorizedError(err) {
|
|
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
|
|
Message: "You are not authorized to use this endpoint.",
|
|
})
|
|
return
|
|
}
|
|
httpapi.InternalServerError(rw, err)
|
|
return
|
|
}
|
|
httpapi.Write(ctx, rw, http.StatusOK, proxies)
|
|
}
|
|
|
|
func (api *API) fetchWorkspaceProxies(ctx context.Context) (codersdk.RegionsResponse[codersdk.WorkspaceProxy], error) {
|
|
proxies, err := api.Database.GetWorkspaceProxies(ctx)
|
|
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
|
|
return codersdk.RegionsResponse[codersdk.WorkspaceProxy]{}, err
|
|
}
|
|
|
|
// Add the primary as well
|
|
primaryProxy, err := api.AGPL.PrimaryWorkspaceProxy(ctx)
|
|
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
|
|
return codersdk.RegionsResponse[codersdk.WorkspaceProxy]{}, err
|
|
}
|
|
proxies = append([]database.WorkspaceProxy{primaryProxy}, proxies...)
|
|
|
|
statues := api.ProxyHealth.HealthStatus()
|
|
return codersdk.RegionsResponse[codersdk.WorkspaceProxy]{
|
|
Regions: convertProxies(proxies, statues),
|
|
}, nil
|
|
}
|
|
|
|
// @Summary Issue signed workspace app token
|
|
// @ID issue-signed-workspace-app-token
|
|
// @Security CoderSessionToken
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Tags Enterprise
|
|
// @Param request body workspaceapps.IssueTokenRequest true "Issue signed app token request"
|
|
// @Success 201 {object} wsproxysdk.IssueSignedAppTokenResponse
|
|
// @Router /workspaceproxies/me/issue-signed-app-token [post]
|
|
// @x-apidocgen {"skip": true}
|
|
func (api *API) workspaceProxyIssueSignedAppToken(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
|
|
// NOTE: this endpoint will return JSON on success, but will (usually)
|
|
// return a self-contained HTML error page on failure. The external proxy
|
|
// should forward any non-201 response to the client.
|
|
|
|
var req workspaceapps.IssueTokenRequest
|
|
if !httpapi.Read(ctx, rw, r, &req) {
|
|
return
|
|
}
|
|
|
|
// userReq is a http request from the user on the other side of the proxy.
|
|
// Although the workspace proxy is making this call, we want to use the user's
|
|
// authorization context to create the token.
|
|
//
|
|
// We can use the existing request context for all tracing/logging purposes.
|
|
// Any workspace proxy auth uses different context keys so we don't need to
|
|
// worry about that.
|
|
userReq, err := http.NewRequestWithContext(ctx, "GET", req.AppRequest.BasePath, nil)
|
|
if err != nil {
|
|
// This should never happen
|
|
httpapi.InternalServerError(rw, xerrors.Errorf("[DEV ERROR] new request: %w", err))
|
|
return
|
|
}
|
|
userReq.Header.Set(codersdk.SessionTokenHeader, req.SessionToken)
|
|
|
|
// Exchange the token.
|
|
token, tokenStr, ok := api.AGPL.WorkspaceAppsProvider.Issue(ctx, rw, userReq, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
if token == nil {
|
|
httpapi.InternalServerError(rw, xerrors.New("nil token after calling token provider"))
|
|
return
|
|
}
|
|
|
|
httpapi.Write(ctx, rw, http.StatusCreated, wsproxysdk.IssueSignedAppTokenResponse{
|
|
SignedTokenStr: tokenStr,
|
|
})
|
|
}
|
|
|
|
// @Summary Report workspace app stats
|
|
// @ID report-workspace-app-stats
|
|
// @Security CoderSessionToken
|
|
// @Accept json
|
|
// @Tags Enterprise
|
|
// @Param request body wsproxysdk.ReportAppStatsRequest true "Report app stats request"
|
|
// @Success 204
|
|
// @Router /workspaceproxies/me/app-stats [post]
|
|
// @x-apidocgen {"skip": true}
|
|
func (api *API) workspaceProxyReportAppStats(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
_ = httpmw.WorkspaceProxy(r) // Ensure the proxy is authenticated.
|
|
|
|
var req wsproxysdk.ReportAppStatsRequest
|
|
if !httpapi.Read(ctx, rw, r, &req) {
|
|
return
|
|
}
|
|
|
|
api.Logger.Debug(ctx, "report app stats", slog.F("stats", req.Stats))
|
|
|
|
reporter := api.WorkspaceAppsStatsCollectorOptions.Reporter
|
|
if err := reporter.Report(ctx, req.Stats); err != nil {
|
|
api.Logger.Error(ctx, "report app stats failed", slog.Error(err))
|
|
httpapi.InternalServerError(rw, err)
|
|
return
|
|
}
|
|
|
|
httpapi.Write(ctx, rw, http.StatusNoContent, nil)
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// This is called periodically by the proxy in the background (every 30s per
|
|
// replica) to ensure that the proxy is still registered and the corresponding
|
|
// replica table entry is refreshed.
|
|
//
|
|
// @Summary Register workspace proxy
|
|
// @ID register-workspace-proxy
|
|
// @Security CoderSessionToken
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Tags Enterprise
|
|
// @Param request body wsproxysdk.RegisterWorkspaceProxyRequest true "Register workspace proxy 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
|
|
}
|
|
|
|
// NOTE: we previously enforced version checks when registering, but this
|
|
// will cause proxies to enter crash loop backoff if the server is updated
|
|
// and the proxy is not. Most releases do not make backwards-incompatible
|
|
// changes to the proxy API, so instead of blocking requests we will show
|
|
// healthcheck warnings.
|
|
|
|
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 := appurl.CompileHostnamePattern(req.WildcardHostname); err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Wildcard URL is invalid.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
if req.ReplicaID == uuid.Nil {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Replica ID is invalid.",
|
|
})
|
|
return
|
|
}
|
|
|
|
if req.DerpOnly && !req.DerpEnabled {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "DerpOnly cannot be true when DerpEnabled is false.",
|
|
})
|
|
return
|
|
}
|
|
|
|
startingRegionID, _ := getProxyDERPStartingRegionID(api.Options.BaseDERPMap)
|
|
regionID := int32(startingRegionID) + proxy.RegionID
|
|
|
|
err := api.Database.InTx(func(db database.Store) error {
|
|
// First, update the proxy's values in the database.
|
|
_, err := db.RegisterWorkspaceProxy(ctx, database.RegisterWorkspaceProxyParams{
|
|
ID: proxy.ID,
|
|
Url: req.AccessURL,
|
|
DerpEnabled: req.DerpEnabled,
|
|
DerpOnly: req.DerpOnly,
|
|
WildcardHostname: req.WildcardHostname,
|
|
Version: req.Version,
|
|
})
|
|
if err != nil {
|
|
return xerrors.Errorf("register workspace proxy: %w", err)
|
|
}
|
|
|
|
// Second, find the replica that corresponds to this proxy and refresh
|
|
// it if it exists. If it doesn't exist, create it.
|
|
now := time.Now()
|
|
replica, err := db.GetReplicaByID(ctx, req.ReplicaID)
|
|
if err == nil {
|
|
// Replica exists, update it.
|
|
if replica.StoppedAt.Valid && !replica.StartedAt.IsZero() {
|
|
// If the replica deregistered, it shouldn't be able to
|
|
// re-register before restarting.
|
|
// TODO: sadly this results in 500 when it should be 400
|
|
return xerrors.Errorf("replica %s is marked stopped", replica.ID)
|
|
}
|
|
|
|
replica, err = db.UpdateReplica(ctx, database.UpdateReplicaParams{
|
|
ID: replica.ID,
|
|
UpdatedAt: now,
|
|
StartedAt: replica.StartedAt,
|
|
StoppedAt: replica.StoppedAt,
|
|
RelayAddress: req.ReplicaRelayAddress,
|
|
RegionID: regionID,
|
|
Hostname: req.ReplicaHostname,
|
|
Version: req.Version,
|
|
Error: req.ReplicaError,
|
|
DatabaseLatency: 0,
|
|
Primary: false,
|
|
})
|
|
if err != nil {
|
|
return xerrors.Errorf("update replica: %w", err)
|
|
}
|
|
} else if xerrors.Is(err, sql.ErrNoRows) {
|
|
// Replica doesn't exist, create it.
|
|
replica, err = db.InsertReplica(ctx, database.InsertReplicaParams{
|
|
ID: req.ReplicaID,
|
|
CreatedAt: now,
|
|
StartedAt: now,
|
|
UpdatedAt: now,
|
|
Hostname: req.ReplicaHostname,
|
|
RegionID: regionID,
|
|
RelayAddress: req.ReplicaRelayAddress,
|
|
Version: req.Version,
|
|
DatabaseLatency: 0,
|
|
Primary: false,
|
|
})
|
|
if err != nil {
|
|
return xerrors.Errorf("insert replica: %w", err)
|
|
}
|
|
} else {
|
|
return xerrors.Errorf("get replica: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}, nil)
|
|
if httpapi.Is404Error(err) {
|
|
httpapi.ResourceNotFound(rw)
|
|
return
|
|
}
|
|
if err != nil {
|
|
httpapi.InternalServerError(rw, err)
|
|
return
|
|
}
|
|
|
|
// Update replica sync and notify all other replicas to update their
|
|
// replica list.
|
|
err = api.replicaManager.PublishUpdate()
|
|
if err != nil {
|
|
httpapi.InternalServerError(rw, err)
|
|
return
|
|
}
|
|
replicaUpdateCtx, replicaUpdateCancel := context.WithTimeout(ctx, 5*time.Second)
|
|
defer replicaUpdateCancel()
|
|
err = api.replicaManager.UpdateNow(replicaUpdateCtx)
|
|
if err != nil {
|
|
httpapi.InternalServerError(rw, err)
|
|
return
|
|
}
|
|
|
|
// Find sibling regions to respond with for derpmesh.
|
|
siblings := api.replicaManager.InRegion(regionID)
|
|
siblingsRes := make([]codersdk.Replica, 0, len(siblings))
|
|
for _, replica := range siblings {
|
|
if replica.ID == req.ReplicaID {
|
|
continue
|
|
}
|
|
siblingsRes = append(siblingsRes, convertReplica(replica))
|
|
}
|
|
|
|
httpapi.Write(ctx, rw, http.StatusCreated, wsproxysdk.RegisterWorkspaceProxyResponse{
|
|
AppSecurityKey: api.AppSecurityKey.String(),
|
|
DERPMeshKey: api.DERPServer.MeshKey(),
|
|
DERPRegionID: regionID,
|
|
DERPMap: api.AGPL.DERPMap(),
|
|
DERPForceWebSockets: api.DeploymentValues.DERP.Config.ForceWebSockets.Value(),
|
|
SiblingReplicas: siblingsRes,
|
|
})
|
|
|
|
go api.forceWorkspaceProxyHealthUpdate(api.ctx)
|
|
}
|
|
|
|
// @Summary Deregister workspace proxy
|
|
// @ID deregister-workspace-proxy
|
|
// @Security CoderSessionToken
|
|
// @Accept json
|
|
// @Tags Enterprise
|
|
// @Param request body wsproxysdk.DeregisterWorkspaceProxyRequest true "Deregister workspace proxy request"
|
|
// @Success 204
|
|
// @Router /workspaceproxies/me/deregister [post]
|
|
// @x-apidocgen {"skip": true}
|
|
func (api *API) workspaceProxyDeregister(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
|
|
var req wsproxysdk.DeregisterWorkspaceProxyRequest
|
|
if !httpapi.Read(ctx, rw, r, &req) {
|
|
return
|
|
}
|
|
|
|
err := api.Database.InTx(func(db database.Store) error {
|
|
now := time.Now()
|
|
replica, err := db.GetReplicaByID(ctx, req.ReplicaID)
|
|
if err != nil {
|
|
return xerrors.Errorf("get replica: %w", err)
|
|
}
|
|
|
|
if replica.StoppedAt.Valid && !replica.StartedAt.IsZero() {
|
|
// TODO: sadly this results in 500 when it should be 400
|
|
return xerrors.Errorf("replica %s is already marked stopped", replica.ID)
|
|
}
|
|
|
|
replica, err = db.UpdateReplica(ctx, database.UpdateReplicaParams{
|
|
ID: replica.ID,
|
|
UpdatedAt: now,
|
|
StartedAt: replica.StartedAt,
|
|
StoppedAt: sql.NullTime{
|
|
Valid: true,
|
|
Time: now,
|
|
},
|
|
RelayAddress: replica.RelayAddress,
|
|
RegionID: replica.RegionID,
|
|
Hostname: replica.Hostname,
|
|
Version: replica.Version,
|
|
Error: replica.Error,
|
|
DatabaseLatency: replica.DatabaseLatency,
|
|
Primary: replica.Primary,
|
|
})
|
|
if err != nil {
|
|
return xerrors.Errorf("update replica: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}, nil)
|
|
if httpapi.Is404Error(err) {
|
|
httpapi.ResourceNotFound(rw)
|
|
return
|
|
}
|
|
if err != nil {
|
|
httpapi.InternalServerError(rw, err)
|
|
return
|
|
}
|
|
|
|
// Publish a replicasync event with a nil ID so every replica (yes, even the
|
|
// current replica) will refresh its replicas list.
|
|
err = api.Pubsub.Publish(replicasync.PubsubEvent, []byte(uuid.Nil.String()))
|
|
if err != nil {
|
|
httpapi.InternalServerError(rw, err)
|
|
return
|
|
}
|
|
|
|
rw.WriteHeader(http.StatusNoContent)
|
|
go api.forceWorkspaceProxyHealthUpdate(api.ctx)
|
|
}
|
|
|
|
// reconnectingPTYSignedToken issues a signed app token for use when connecting
|
|
// to the reconnecting PTY websocket on an external workspace proxy. This is set
|
|
// by the client as a query parameter when connecting.
|
|
//
|
|
// @Summary Issue signed app token for reconnecting PTY
|
|
// @ID issue-signed-app-token-for-reconnecting-pty
|
|
// @Security CoderSessionToken
|
|
// @Tags Enterprise
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body codersdk.IssueReconnectingPTYSignedTokenRequest true "Issue reconnecting PTY signed token request"
|
|
// @Success 200 {object} codersdk.IssueReconnectingPTYSignedTokenResponse
|
|
// @Router /applications/reconnecting-pty-signed-token [post]
|
|
// @x-apidocgen {"skip": true}
|
|
func (api *API) reconnectingPTYSignedToken(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
apiKey := httpmw.APIKey(r)
|
|
if !api.Authorize(r, policy.ActionCreate, apiKey) {
|
|
httpapi.ResourceNotFound(rw)
|
|
return
|
|
}
|
|
|
|
var req codersdk.IssueReconnectingPTYSignedTokenRequest
|
|
if !httpapi.Read(ctx, rw, r, &req) {
|
|
return
|
|
}
|
|
|
|
u, err := url.Parse(req.URL)
|
|
if err == nil && u.Scheme != "ws" && u.Scheme != "wss" {
|
|
err = xerrors.Errorf("invalid URL scheme %q, expected 'ws' or 'wss'", u.Scheme)
|
|
}
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Invalid URL.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
// Assert the URL is a valid reconnecting-pty URL.
|
|
expectedPath := fmt.Sprintf("/api/v2/workspaceagents/%s/pty", req.AgentID.String())
|
|
if u.Path != expectedPath {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Invalid URL path.",
|
|
Detail: "The provided URL is not a valid reconnecting PTY endpoint URL.",
|
|
})
|
|
return
|
|
}
|
|
|
|
scheme, err := api.AGPL.ValidWorkspaceAppHostname(ctx, u.Host, agpl.ValidWorkspaceAppHostnameOpts{
|
|
// Only allow the proxy access URL as a hostname since we don't need a
|
|
// ticket for the primary dashboard URL terminal.
|
|
AllowPrimaryAccessURL: false,
|
|
AllowPrimaryWildcard: false,
|
|
AllowProxyAccessURL: true,
|
|
AllowProxyWildcard: false,
|
|
})
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Failed to verify hostname in URL.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
if scheme == "" {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Invalid hostname in URL.",
|
|
Detail: "The hostname must be the primary wildcard app hostname, a workspace proxy access URL or a workspace proxy wildcard app hostname.",
|
|
})
|
|
return
|
|
}
|
|
|
|
_, tokenStr, ok := api.AGPL.WorkspaceAppsProvider.Issue(ctx, rw, r, workspaceapps.IssueTokenRequest{
|
|
AppRequest: workspaceapps.Request{
|
|
AccessMethod: workspaceapps.AccessMethodTerminal,
|
|
BasePath: u.Path,
|
|
AgentNameOrID: req.AgentID.String(),
|
|
},
|
|
SessionToken: httpmw.APITokenFromRequest(r),
|
|
// The following fields aren't required as long as the request is authed
|
|
// with a valid API key, which we know since this endpoint is protected
|
|
// by auth middleware already.
|
|
PathAppBaseURL: "",
|
|
AppHostname: "",
|
|
// The following fields are empty for terminal apps.
|
|
AppPath: "",
|
|
AppQuery: "",
|
|
})
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, codersdk.IssueReconnectingPTYSignedTokenResponse{
|
|
SignedToken: tokenStr,
|
|
})
|
|
}
|
|
|
|
func generateWorkspaceProxyToken(id uuid.UUID) (token string, hashed []byte, err error) {
|
|
secret, err := cryptorand.HexString(64)
|
|
if err != nil {
|
|
return "", nil, xerrors.Errorf("generate token: %w", err)
|
|
}
|
|
hashedSecret := sha256.Sum256([]byte(secret))
|
|
fullToken := fmt.Sprintf("%s:%s", id, secret)
|
|
return fullToken, hashedSecret[:], nil
|
|
}
|
|
|
|
func convertProxies(p []database.WorkspaceProxy, statuses map[uuid.UUID]proxyhealth.ProxyStatus) []codersdk.WorkspaceProxy {
|
|
resp := make([]codersdk.WorkspaceProxy, 0, len(p))
|
|
for _, proxy := range p {
|
|
resp = append(resp, convertProxy(proxy, statuses[proxy.ID]))
|
|
}
|
|
return resp
|
|
}
|
|
|
|
func convertRegion(proxy database.WorkspaceProxy, status proxyhealth.ProxyStatus) codersdk.Region {
|
|
return codersdk.Region{
|
|
ID: proxy.ID,
|
|
Name: proxy.Name,
|
|
DisplayName: proxy.DisplayName,
|
|
IconURL: proxy.Icon,
|
|
Healthy: status.Status == proxyhealth.Healthy,
|
|
PathAppURL: proxy.Url,
|
|
WildcardHostname: proxy.WildcardHostname,
|
|
}
|
|
}
|
|
|
|
func convertProxy(p database.WorkspaceProxy, status proxyhealth.ProxyStatus) codersdk.WorkspaceProxy {
|
|
now := dbtime.Now()
|
|
if p.IsPrimary() {
|
|
// Primary is always healthy since the primary serves the api that this
|
|
// is returned from.
|
|
u, _ := url.Parse(p.Url)
|
|
status = proxyhealth.ProxyStatus{
|
|
Proxy: p,
|
|
ProxyHost: u.Host,
|
|
Status: proxyhealth.Healthy,
|
|
Report: codersdk.ProxyHealthReport{},
|
|
CheckedAt: now,
|
|
}
|
|
// For primary, created at / updated at are always 'now'
|
|
p.CreatedAt = now
|
|
p.UpdatedAt = now
|
|
}
|
|
if status.Status == "" {
|
|
status.Status = proxyhealth.Unknown
|
|
}
|
|
if status.Report.Errors == nil {
|
|
status.Report.Errors = make([]string, 0)
|
|
}
|
|
if status.Report.Warnings == nil {
|
|
status.Report.Warnings = make([]string, 0)
|
|
}
|
|
return codersdk.WorkspaceProxy{
|
|
Region: convertRegion(p, status),
|
|
DerpEnabled: p.DerpEnabled,
|
|
DerpOnly: p.DerpOnly,
|
|
CreatedAt: p.CreatedAt,
|
|
UpdatedAt: p.UpdatedAt,
|
|
Deleted: p.Deleted,
|
|
Version: p.Version,
|
|
Status: codersdk.WorkspaceProxyStatus{
|
|
Status: codersdk.ProxyHealthStatus(status.Status),
|
|
Report: status.Report,
|
|
CheckedAt: status.CheckedAt,
|
|
},
|
|
}
|
|
}
|
|
|
|
// workspaceProxiesFetchUpdater implements healthcheck.WorkspaceProxyFetchUpdater
|
|
// in an actually useful and meaningful way.
|
|
type workspaceProxiesFetchUpdater struct {
|
|
fetchFunc func(context.Context) (codersdk.RegionsResponse[codersdk.WorkspaceProxy], error)
|
|
updateFunc func(context.Context) error
|
|
}
|
|
|
|
func (w *workspaceProxiesFetchUpdater) Fetch(ctx context.Context) (codersdk.RegionsResponse[codersdk.WorkspaceProxy], error) {
|
|
//nolint:gocritic // Need perms to read all workspace proxies.
|
|
authCtx := dbauthz.AsSystemRestricted(ctx)
|
|
return w.fetchFunc(authCtx)
|
|
}
|
|
|
|
func (w *workspaceProxiesFetchUpdater) Update(ctx context.Context) error {
|
|
return w.updateFunc(ctx)
|
|
}
|