mirror of https://github.com/coder/coder.git
961 lines
30 KiB
Go
961 lines
30 KiB
Go
package coderd
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"flag"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"golang.org/x/xerrors"
|
|
|
|
"cdr.dev/slog"
|
|
"github.com/coder/coder/v2/buildinfo"
|
|
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"
|
|
"github.com/coder/coder/v2/coderd/telemetry"
|
|
"github.com/coder/coder/v2/coderd/workspaceapps"
|
|
"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)
|
|
// TODO: This audit log does not work because it has no user id
|
|
// associated with it. The audit log commitAudit() function ignores
|
|
// the audit log if there is no user id. We should find a solution
|
|
// to make sure this event is tracked.
|
|
// 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 wsproxysdk.RegisterWorkspaceProxyRequest
|
|
if !httpapi.Read(ctx, rw, r, &req) {
|
|
return
|
|
}
|
|
|
|
// Version check should be forced in non-dev builds and when running in
|
|
// tests.
|
|
shouldForceVersion := !buildinfo.IsDev() || flag.Lookup("test.v") != nil
|
|
if shouldForceVersion && req.Version != buildinfo.Version() {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Version mismatch.",
|
|
Detail: fmt.Sprintf("Proxy version %q does not match primary server version %q", req.Version, buildinfo.Version()),
|
|
})
|
|
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
|
|
}
|
|
}
|
|
|
|
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,
|
|
})
|
|
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 if err != nil {
|
|
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))
|
|
}
|
|
|
|
// aReq.New = updatedProxy
|
|
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 Applications Enterprise
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body codersdk.IssueReconnectingPTYSignedTokenRequest true "Issue reconnecting PTY signed token request"
|
|
// @Success 200 {object} codersdk.IssueReconnectingPTYSignedTokenResponse
|
|
// @Router /applications/reconnecting-pty-signed-token [post]
|
|
// @x-apidocgen {"skip": true}
|
|
func (api *API) reconnectingPTYSignedToken(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
apiKey := httpmw.APIKey(r)
|
|
if !api.Authorize(r, rbac.ActionCreate, apiKey) {
|
|
httpapi.ResourceNotFound(rw)
|
|
return
|
|
}
|
|
|
|
var req codersdk.IssueReconnectingPTYSignedTokenRequest
|
|
if !httpapi.Read(ctx, rw, r, &req) {
|
|
return
|
|
}
|
|
|
|
u, err := url.Parse(req.URL)
|
|
if err == nil && u.Scheme != "ws" && u.Scheme != "wss" {
|
|
err = xerrors.Errorf("invalid URL scheme %q, expected 'ws' or 'wss'", u.Scheme)
|
|
}
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Invalid URL.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
// Assert the URL is a valid reconnecting-pty URL.
|
|
expectedPath := fmt.Sprintf("/api/v2/workspaceagents/%s/pty", req.AgentID.String())
|
|
if u.Path != expectedPath {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Invalid URL path.",
|
|
Detail: "The provided URL is not a valid reconnecting PTY endpoint URL.",
|
|
})
|
|
return
|
|
}
|
|
|
|
scheme, err := api.AGPL.ValidWorkspaceAppHostname(ctx, u.Host, agpl.ValidWorkspaceAppHostnameOpts{
|
|
// Only allow the proxy access URL as a hostname since we don't need a
|
|
// ticket for the primary dashboard URL terminal.
|
|
AllowPrimaryAccessURL: false,
|
|
AllowPrimaryWildcard: false,
|
|
AllowProxyAccessURL: true,
|
|
AllowProxyWildcard: false,
|
|
})
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Failed to verify hostname in URL.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
if scheme == "" {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Invalid hostname in URL.",
|
|
Detail: "The hostname must be the primary wildcard app hostname, a workspace proxy access URL or a workspace proxy wildcard app hostname.",
|
|
})
|
|
return
|
|
}
|
|
|
|
_, tokenStr, ok := api.AGPL.WorkspaceAppsProvider.Issue(ctx, rw, r, workspaceapps.IssueTokenRequest{
|
|
AppRequest: workspaceapps.Request{
|
|
AccessMethod: workspaceapps.AccessMethodTerminal,
|
|
BasePath: u.Path,
|
|
AgentNameOrID: req.AgentID.String(),
|
|
},
|
|
SessionToken: httpmw.APITokenFromRequest(r),
|
|
// The following fields aren't required as long as the request is authed
|
|
// with a valid API key, 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 {
|
|
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: time.Now(),
|
|
}
|
|
}
|
|
if status.Status == "" {
|
|
status.Status = proxyhealth.Unknown
|
|
}
|
|
return codersdk.WorkspaceProxy{
|
|
Region: convertRegion(p, status),
|
|
DerpEnabled: p.DerpEnabled,
|
|
DerpOnly: p.DerpOnly,
|
|
CreatedAt: p.CreatedAt,
|
|
UpdatedAt: p.UpdatedAt,
|
|
Deleted: p.Deleted,
|
|
Status: codersdk.WorkspaceProxyStatus{
|
|
Status: codersdk.ProxyHealthStatus(status.Status),
|
|
Report: status.Report,
|
|
CheckedAt: status.CheckedAt,
|
|
},
|
|
}
|
|
}
|