coder/coderd/coderd.go

1026 lines
35 KiB
Go

package coderd
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"net/url"
"path/filepath"
"regexp"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/andybalholm/brotli"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/google/uuid"
"github.com/klauspost/compress/zstd"
"github.com/moby/moby/pkg/namesgenerator"
"github.com/prometheus/client_golang/prometheus"
httpSwagger "github.com/swaggo/http-swagger/v2"
"go.opentelemetry.io/otel/trace"
"golang.org/x/xerrors"
"google.golang.org/api/idtoken"
"storj.io/drpc/drpcmux"
"storj.io/drpc/drpcserver"
"tailscale.com/derp"
"tailscale.com/derp/derphttp"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
"tailscale.com/util/singleflight"
"cdr.dev/slog"
"github.com/coder/coder/buildinfo"
"github.com/coder/coder/codersdk/agentsdk"
// Used for swagger docs.
_ "github.com/coder/coder/coderd/apidoc"
"github.com/coder/coder/coderd/audit"
"github.com/coder/coder/coderd/awsidentity"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/database/dbauthz"
"github.com/coder/coder/coderd/database/dbmetrics"
"github.com/coder/coder/coderd/database/dbtype"
"github.com/coder/coder/coderd/gitauth"
"github.com/coder/coder/coderd/gitsshkey"
"github.com/coder/coder/coderd/healthcheck"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/httpmw"
"github.com/coder/coder/coderd/metricscache"
"github.com/coder/coder/coderd/provisionerdserver"
"github.com/coder/coder/coderd/rbac"
"github.com/coder/coder/coderd/schedule"
"github.com/coder/coder/coderd/telemetry"
"github.com/coder/coder/coderd/tracing"
"github.com/coder/coder/coderd/updatecheck"
"github.com/coder/coder/coderd/util/slice"
"github.com/coder/coder/coderd/workspaceapps"
"github.com/coder/coder/coderd/wsconncache"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/provisionerd/proto"
"github.com/coder/coder/provisionersdk"
"github.com/coder/coder/site"
"github.com/coder/coder/tailnet"
)
// We must only ever instantiate one httpSwagger.Handler because of a data race
// inside the handler. This issue is triggered by tests that create multiple
// coderd instances.
//
// See https://github.com/swaggo/http-swagger/issues/78
var globalHTTPSwaggerHandler http.HandlerFunc
func init() {
globalHTTPSwaggerHandler = httpSwagger.Handler(httpSwagger.URL("/swagger/doc.json"))
}
// Options are requires parameters for Coder to start.
type Options struct {
AccessURL *url.URL
// AppHostname should be the wildcard hostname to use for workspace
// applications INCLUDING the asterisk, (optional) suffix and leading dot.
// It will use the same scheme and port number as the access URL.
// E.g. "*.apps.coder.com" or "*-apps.coder.com".
AppHostname string
// AppHostnameRegex contains the regex version of options.AppHostname as
// generated by httpapi.CompileHostnamePattern(). It MUST be set if
// options.AppHostname is set.
AppHostnameRegex *regexp.Regexp
Logger slog.Logger
Database database.Store
Pubsub database.Pubsub
// CacheDir is used for caching files served by the API.
CacheDir string
Auditor audit.Auditor
AgentConnectionUpdateFrequency time.Duration
AgentInactiveDisconnectTimeout time.Duration
AWSCertificates awsidentity.Certificates
Authorizer rbac.Authorizer
AzureCertificates x509.VerifyOptions
GoogleTokenValidator *idtoken.Validator
GithubOAuth2Config *GithubOAuth2Config
OIDCConfig *OIDCConfig
PrometheusRegistry *prometheus.Registry
SecureAuthCookie bool
StrictTransportSecurityCfg httpmw.HSTSConfig
SSHKeygenAlgorithm gitsshkey.Algorithm
Telemetry telemetry.Reporter
TracerProvider trace.TracerProvider
GitAuthConfigs []*gitauth.Config
RealIPConfig *httpmw.RealIPConfig
TrialGenerator func(ctx context.Context, email string) error
// TLSCertificates is used to mesh DERP servers securely.
TLSCertificates []tls.Certificate
TailnetCoordinator tailnet.Coordinator
DERPServer *derp.Server
DERPMap *tailcfg.DERPMap
SwaggerEndpoint bool
SetUserGroups func(ctx context.Context, tx database.Store, userID uuid.UUID, groupNames []string) error
TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore]
// AppSecurityKey is the crypto key used to sign and encrypt tokens related to
// workspace applications. It consists of both a signing and encryption key.
AppSecurityKey workspaceapps.SecurityKey
HealthcheckFunc func(ctx context.Context, apiKey string) *healthcheck.Report
HealthcheckTimeout time.Duration
HealthcheckRefresh time.Duration
// APIRateLimit is the minutely throughput rate limit per user or ip.
// Setting a rate limit <0 will disable the rate limiter across the entire
// app. Some specific routes have their own configurable rate limits.
APIRateLimit int
LoginRateLimit int
FilesRateLimit int
MetricsCacheRefreshInterval time.Duration
AgentStatsRefreshInterval time.Duration
DeploymentValues *codersdk.DeploymentValues
UpdateCheckOptions *updatecheck.Options // Set non-nil to enable update checking.
// SSHConfig is the response clients use to configure config-ssh locally.
SSHConfig codersdk.SSHConfigResponse
HTTPClient *http.Client
UpdateAgentMetrics func(ctx context.Context, username, workspaceName, agentName string, metrics []agentsdk.AgentMetric)
}
// @title Coder API
// @version 2.0
// @description Coderd is the service created by running coder server. It is a thin API that connects workspaces, provisioners and users. coderd stores its state in Postgres and is the only service that communicates with Postgres.
// @termsOfService https://coder.com/legal/terms-of-service
// @contact.name API Support
// @contact.url https://coder.com
// @contact.email support@coder.com
// @license.name AGPL-3.0
// @license.url https://github.com/coder/coder/blob/main/LICENSE
// @BasePath /api/v2
// @securitydefinitions.apiKey CoderSessionToken
// @in header
// @name Coder-Session-Token
// New constructs a Coder API handler.
func New(options *Options) *API {
if options == nil {
options = &Options{}
}
// Safety check: if we're not running a unit test, we *must* have a Prometheus registry.
if options.PrometheusRegistry == nil && flag.Lookup("test.v") == nil {
panic("developer error: options.PrometheusRegistry is nil and not running a unit test")
}
if options.DeploymentValues.DisableOwnerWorkspaceExec {
rbac.ReloadBuiltinRoles(&rbac.RoleOptions{
NoOwnerWorkspaceExec: true,
})
}
if options.Authorizer == nil {
options.Authorizer = rbac.NewCachingAuthorizer(options.PrometheusRegistry)
}
// The below are no-ops if already wrapped.
if options.PrometheusRegistry != nil {
options.Database = dbmetrics.New(options.Database, options.PrometheusRegistry)
}
options.Database = dbauthz.New(
options.Database,
options.Authorizer,
options.Logger.Named("authz_querier"),
)
experiments := initExperiments(
options.Logger, options.DeploymentValues.Experiments.Value(),
)
if options.AppHostname != "" && options.AppHostnameRegex == nil || options.AppHostname == "" && options.AppHostnameRegex != nil {
panic("coderd: both AppHostname and AppHostnameRegex must be set or unset")
}
if options.AgentConnectionUpdateFrequency == 0 {
options.AgentConnectionUpdateFrequency = 15 * time.Second
}
if options.AgentInactiveDisconnectTimeout == 0 {
// Multiply the update by two to allow for some lag-time.
options.AgentInactiveDisconnectTimeout = options.AgentConnectionUpdateFrequency * 2
// Set a minimum timeout to avoid disconnecting too soon.
if options.AgentInactiveDisconnectTimeout < 2*time.Second {
options.AgentInactiveDisconnectTimeout = 2 * time.Second
}
}
if options.AgentStatsRefreshInterval == 0 {
options.AgentStatsRefreshInterval = 5 * time.Minute
}
if options.MetricsCacheRefreshInterval == 0 {
options.MetricsCacheRefreshInterval = time.Hour
}
if options.APIRateLimit == 0 {
options.APIRateLimit = 512
}
if options.LoginRateLimit == 0 {
options.LoginRateLimit = 60
}
if options.FilesRateLimit == 0 {
options.FilesRateLimit = 12
}
if options.PrometheusRegistry == nil {
options.PrometheusRegistry = prometheus.NewRegistry()
}
if options.TailnetCoordinator == nil {
options.TailnetCoordinator = tailnet.NewCoordinator(options.Logger)
}
if options.DERPServer == nil {
options.DERPServer = derp.NewServer(key.NewNode(), tailnet.Logger(options.Logger.Named("derp")))
}
if options.Auditor == nil {
options.Auditor = audit.NewNop()
}
if options.SSHConfig.HostnamePrefix == "" {
options.SSHConfig.HostnamePrefix = "coder."
}
if options.TracerProvider == nil {
options.TracerProvider = trace.NewNoopTracerProvider()
}
if options.SetUserGroups == nil {
options.SetUserGroups = func(ctx context.Context, _ database.Store, id uuid.UUID, groups []string) error {
options.Logger.Warn(ctx, "attempted to assign OIDC groups without enterprise license",
slog.F("id", id), slog.F("groups", groups),
)
return nil
}
}
if options.TemplateScheduleStore == nil {
options.TemplateScheduleStore = &atomic.Pointer[schedule.TemplateScheduleStore]{}
}
if options.TemplateScheduleStore.Load() == nil {
v := schedule.NewAGPLTemplateScheduleStore()
options.TemplateScheduleStore.Store(&v)
}
if options.HealthcheckFunc == nil {
options.HealthcheckFunc = func(ctx context.Context, apiKey string) *healthcheck.Report {
return healthcheck.Run(ctx, &healthcheck.ReportOptions{
AccessURL: options.AccessURL,
DERPMap: options.DERPMap.Clone(),
APIKey: apiKey,
})
}
}
if options.HealthcheckTimeout == 0 {
options.HealthcheckTimeout = 30 * time.Second
}
if options.HealthcheckRefresh == 0 {
options.HealthcheckRefresh = 10 * time.Minute
}
siteCacheDir := options.CacheDir
if siteCacheDir != "" {
siteCacheDir = filepath.Join(siteCacheDir, "site")
}
binFS, binHashes, err := site.ExtractOrReadBinFS(siteCacheDir, site.FS())
if err != nil {
panic(xerrors.Errorf("read site bin failed: %w", err))
}
metricsCache := metricscache.New(
options.Database,
options.Logger.Named("metrics_cache"),
metricscache.Intervals{
TemplateDAUs: options.MetricsCacheRefreshInterval,
DeploymentStats: options.AgentStatsRefreshInterval,
},
)
staticHandler := site.Handler(site.FS(), binFS, binHashes)
// Static file handler must be wrapped with HSTS handler if the
// StrictTransportSecurityAge is set. We only need to set this header on
// static files since it only affects browsers.
staticHandler = httpmw.HSTS(staticHandler, options.StrictTransportSecurityCfg)
oauthConfigs := &httpmw.OAuth2Configs{
Github: options.GithubOAuth2Config,
OIDC: options.OIDCConfig,
}
ctx, cancel := context.WithCancel(context.Background())
r := chi.NewRouter()
api := &API{
ctx: ctx,
cancel: cancel,
ID: uuid.New(),
Options: options,
RootHandler: r,
siteHandler: staticHandler,
HTTPAuth: &HTTPAuthorizer{
Authorizer: options.Authorizer,
Logger: options.Logger,
},
WorkspaceAppsProvider: workspaceapps.NewDBTokenProvider(
options.Logger.Named("workspaceapps"),
options.AccessURL,
options.Authorizer,
options.Database,
options.DeploymentValues,
oauthConfigs,
options.AgentInactiveDisconnectTimeout,
options.AppSecurityKey,
),
metricsCache: metricsCache,
Auditor: atomic.Pointer[audit.Auditor]{},
TemplateScheduleStore: options.TemplateScheduleStore,
Experiments: experiments,
healthCheckGroup: &singleflight.Group[string, *healthcheck.Report]{},
}
if options.UpdateCheckOptions != nil {
api.updateChecker = updatecheck.New(
options.Database,
options.Logger.Named("update_checker"),
*options.UpdateCheckOptions,
)
}
var oidcAuthURLParams map[string]string
if options.OIDCConfig != nil {
oidcAuthURLParams = options.OIDCConfig.AuthURLParams
}
api.Auditor.Store(&options.Auditor)
api.workspaceAgentCache = wsconncache.New(api.dialWorkspaceAgentTailnet, 0)
api.TailnetCoordinator.Store(&options.TailnetCoordinator)
api.workspaceAppServer = &workspaceapps.Server{
Logger: options.Logger.Named("workspaceapps"),
DashboardURL: api.AccessURL,
AccessURL: api.AccessURL,
Hostname: api.AppHostname,
HostnameRegex: api.AppHostnameRegex,
RealIPConfig: options.RealIPConfig,
SignedTokenProvider: api.WorkspaceAppsProvider,
WorkspaceConnCache: api.workspaceAgentCache,
AppSecurityKey: options.AppSecurityKey,
DisablePathApps: options.DeploymentValues.DisablePathApps.Value(),
SecureAuthCookie: options.DeploymentValues.SecureAuthCookie.Value(),
}
apiKeyMiddleware := httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: options.Database,
OAuth2Configs: oauthConfigs,
RedirectToLogin: false,
DisableSessionExpiryRefresh: options.DeploymentValues.DisableSessionExpiryRefresh.Value(),
Optional: false,
})
// Same as above but it redirects to the login page.
apiKeyMiddlewareRedirect := httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: options.Database,
OAuth2Configs: oauthConfigs,
RedirectToLogin: true,
DisableSessionExpiryRefresh: options.DeploymentValues.DisableSessionExpiryRefresh.Value(),
Optional: false,
})
// Same as the first but it's optional.
apiKeyMiddlewareOptional := httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: options.Database,
OAuth2Configs: oauthConfigs,
RedirectToLogin: false,
DisableSessionExpiryRefresh: options.DeploymentValues.DisableSessionExpiryRefresh.Value(),
Optional: true,
})
// API rate limit middleware. The counter is local and not shared between
// replicas or instances of this middleware.
apiRateLimiter := httpmw.RateLimit(options.APIRateLimit, time.Minute)
derpHandler := derphttp.Handler(api.DERPServer)
derpHandler, api.derpCloseFunc = tailnet.WithWebsocketSupport(api.DERPServer, derpHandler)
cors := httpmw.Cors(options.DeploymentValues.Dangerous.AllowAllCors.Value())
r.Use(
cors,
httpmw.Recover(api.Logger),
tracing.StatusWriterMiddleware,
tracing.Middleware(api.TracerProvider),
httpmw.AttachRequestID,
httpmw.ExtractRealIP(api.RealIPConfig),
httpmw.Logger(api.Logger),
httpmw.Prometheus(options.PrometheusRegistry),
// SubdomainAppMW checks if the first subdomain is a valid app URL. If
// it is, it will serve that application.
//
// Workspace apps do their own auth and must be BEFORE the auth
// middleware.
api.workspaceAppServer.HandleSubdomain(apiRateLimiter),
// Build-Version is helpful for debugging.
func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("X-Coder-Build-Version", buildinfo.Version())
next.ServeHTTP(w, r)
})
},
// This header stops a browser from trying to MIME-sniff the content type and
// forces it to stick with the declared content-type. This is the only valid
// value for this header.
// See: https://github.com/coder/security/issues/12
func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("X-Content-Type-Options", "nosniff")
next.ServeHTTP(w, r)
})
},
httpmw.CSRF(options.SecureAuthCookie),
)
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("OK")) })
// Attach workspace apps routes.
r.Group(func(r chi.Router) {
r.Use(apiRateLimiter)
api.workspaceAppServer.Attach(r)
})
r.Route("/derp", func(r chi.Router) {
r.Get("/", derpHandler.ServeHTTP)
// This is used when UDP is blocked, and latency must be checked via HTTP(s).
r.Get("/latency-check", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
})
r.Route("/gitauth", func(r chi.Router) {
for _, gitAuthConfig := range options.GitAuthConfigs {
r.Route(fmt.Sprintf("/%s", gitAuthConfig.ID), func(r chi.Router) {
r.Use(
httpmw.ExtractOAuth2(gitAuthConfig, options.HTTPClient, nil),
apiKeyMiddlewareRedirect,
)
r.Get("/callback", api.gitAuthCallback(gitAuthConfig))
})
}
})
r.Route("/api/v2", func(r chi.Router) {
api.APIHandler = r
r.NotFound(func(rw http.ResponseWriter, r *http.Request) { httpapi.RouteNotFound(rw) })
r.Use(
// Specific routes can specify different limits, but every rate
// limit must be configurable by the admin.
apiRateLimiter,
httpmw.ReportCLITelemetry(api.Logger, options.Telemetry),
)
r.Get("/", apiRoot)
// All CSP errors will be logged
r.Post("/csp/reports", api.logReportCSPViolations)
r.Get("/buildinfo", buildInfo(api.AccessURL))
// /regions is overridden in the enterprise version
r.Group(func(r chi.Router) {
r.Use(apiKeyMiddleware)
r.Get("/regions", api.regions)
})
r.Route("/deployment", func(r chi.Router) {
r.Use(apiKeyMiddleware)
r.Get("/config", api.deploymentValues)
r.Get("/stats", api.deploymentStats)
r.Get("/ssh", api.sshConfig)
})
r.Route("/experiments", func(r chi.Router) {
r.Use(apiKeyMiddleware)
r.Get("/", api.handleExperimentsGet)
})
r.Get("/updatecheck", api.updateCheck)
r.Route("/audit", func(r chi.Router) {
r.Use(
apiKeyMiddleware,
)
r.Get("/", api.auditLogs)
r.Post("/testgenerate", api.generateFakeAuditLog)
})
r.Route("/files", func(r chi.Router) {
r.Use(
apiKeyMiddleware,
httpmw.RateLimit(options.FilesRateLimit, time.Minute),
)
r.Get("/{fileID}", api.fileByID)
r.Post("/", api.postFile)
})
r.Route("/organizations", func(r chi.Router) {
r.Use(
apiKeyMiddleware,
)
r.Post("/", api.postOrganizations)
r.Route("/{organization}", func(r chi.Router) {
r.Use(
httpmw.ExtractOrganizationParam(options.Database),
)
r.Get("/", api.organization)
r.Post("/templateversions", api.postTemplateVersionsByOrganization)
r.Route("/templates", func(r chi.Router) {
r.Post("/", api.postTemplateByOrganization)
r.Get("/", api.templatesByOrganization)
r.Get("/examples", api.templateExamples)
r.Route("/{templatename}", func(r chi.Router) {
r.Get("/", api.templateByOrganizationAndName)
r.Route("/versions/{templateversionname}", func(r chi.Router) {
r.Get("/", api.templateVersionByOrganizationTemplateAndName)
r.Get("/previous", api.previousTemplateVersionByOrganizationTemplateAndName)
})
})
})
r.Route("/members", func(r chi.Router) {
r.Get("/roles", api.assignableOrgRoles)
r.Route("/{user}", func(r chi.Router) {
r.Use(
httpmw.ExtractUserParam(options.Database, false),
httpmw.ExtractOrganizationMemberParam(options.Database),
)
r.Put("/roles", api.putMemberRoles)
r.Post("/workspaces", api.postWorkspacesByOrganization)
})
})
})
})
r.Route("/templates/{template}", func(r chi.Router) {
r.Use(
apiKeyMiddleware,
httpmw.ExtractTemplateParam(options.Database),
)
r.Get("/daus", api.templateDAUs)
r.Get("/", api.template)
r.Delete("/", api.deleteTemplate)
r.Patch("/", api.patchTemplateMeta)
r.Route("/versions", func(r chi.Router) {
r.Get("/", api.templateVersionsByTemplate)
r.Patch("/", api.patchActiveTemplateVersion)
r.Get("/{templateversionname}", api.templateVersionByName)
})
})
r.Route("/templateversions/{templateversion}", func(r chi.Router) {
r.Use(
apiKeyMiddleware,
httpmw.ExtractTemplateVersionParam(options.Database),
)
r.Get("/", api.templateVersion)
r.Patch("/", api.patchTemplateVersion)
r.Patch("/cancel", api.patchCancelTemplateVersion)
// Old agents may expect a non-error response from /schema and /parameters endpoints.
// The idea is to return an empty [], so that the coder CLI won't get blocked accidentally.
r.Get("/schema", templateVersionSchemaDeprecated)
r.Get("/parameters", templateVersionParametersDeprecated)
r.Get("/rich-parameters", api.templateVersionRichParameters)
r.Get("/gitauth", api.templateVersionGitAuth)
r.Get("/variables", api.templateVersionVariables)
r.Get("/resources", api.templateVersionResources)
r.Get("/logs", api.templateVersionLogs)
r.Route("/dry-run", func(r chi.Router) {
r.Post("/", api.postTemplateVersionDryRun)
r.Get("/{jobID}", api.templateVersionDryRun)
r.Get("/{jobID}/resources", api.templateVersionDryRunResources)
r.Get("/{jobID}/logs", api.templateVersionDryRunLogs)
r.Patch("/{jobID}/cancel", api.patchTemplateVersionDryRunCancel)
})
})
r.Route("/users", func(r chi.Router) {
r.Get("/first", api.firstUser)
r.Post("/first", api.postFirstUser)
r.Get("/authmethods", api.userAuthMethods)
r.Group(func(r chi.Router) {
// We use a tight limit for password login to protect against
// audit-log write DoS, pbkdf2 DoS, and simple brute-force
// attacks.
//
// This value is intentionally increased during tests.
r.Use(httpmw.RateLimit(options.LoginRateLimit, time.Minute))
r.Post("/login", api.postLogin)
r.Route("/oauth2", func(r chi.Router) {
r.Route("/github", func(r chi.Router) {
r.Use(httpmw.ExtractOAuth2(options.GithubOAuth2Config, options.HTTPClient, nil))
r.Get("/callback", api.userOAuth2Github)
})
})
r.Route("/oidc/callback", func(r chi.Router) {
r.Use(httpmw.ExtractOAuth2(options.OIDCConfig, options.HTTPClient, oidcAuthURLParams))
r.Get("/", api.userOIDC)
})
})
r.Group(func(r chi.Router) {
r.Use(
apiKeyMiddleware,
)
r.Post("/", api.postUser)
r.Get("/", api.users)
r.Post("/logout", api.postLogout)
// These routes query information about site wide roles.
r.Route("/roles", func(r chi.Router) {
r.Get("/", api.assignableSiteRoles)
})
r.Route("/{user}", func(r chi.Router) {
r.Use(httpmw.ExtractUserParam(options.Database, false))
r.Delete("/", api.deleteUser)
r.Get("/", api.userByName)
r.Put("/profile", api.putUserProfile)
r.Route("/status", func(r chi.Router) {
r.Put("/suspend", api.putSuspendUserAccount())
r.Put("/activate", api.putActivateUserAccount())
})
r.Route("/password", func(r chi.Router) {
r.Put("/", api.putUserPassword)
})
// These roles apply to the site wide permissions.
r.Put("/roles", api.putUserRoles)
r.Get("/roles", api.userRoles)
r.Route("/keys", func(r chi.Router) {
r.Post("/", api.postAPIKey)
r.Route("/tokens", func(r chi.Router) {
r.Post("/", api.postToken)
r.Get("/", api.tokens)
r.Get("/tokenconfig", api.tokenConfig)
r.Route("/{keyname}", func(r chi.Router) {
r.Get("/", api.apiKeyByName)
})
})
r.Route("/{keyid}", func(r chi.Router) {
r.Get("/", api.apiKeyByID)
r.Delete("/", api.deleteAPIKey)
})
})
r.Route("/organizations", func(r chi.Router) {
r.Get("/", api.organizationsByUser)
r.Get("/{organizationname}", api.organizationByUserAndName)
})
r.Route("/workspace/{workspacename}", func(r chi.Router) {
r.Get("/", api.workspaceByOwnerAndName)
r.Get("/builds/{buildnumber}", api.workspaceBuildByBuildNumber)
})
r.Get("/gitsshkey", api.gitSSHKey)
r.Put("/gitsshkey", api.regenerateGitSSHKey)
})
})
})
r.Route("/workspaceagents", func(r chi.Router) {
r.Post("/azure-instance-identity", api.postWorkspaceAuthAzureInstanceIdentity)
r.Post("/aws-instance-identity", api.postWorkspaceAuthAWSInstanceIdentity)
r.Post("/google-instance-identity", api.postWorkspaceAuthGoogleInstanceIdentity)
r.Route("/me", func(r chi.Router) {
r.Use(httpmw.ExtractWorkspaceAgent(options.Database))
r.Get("/manifest", api.workspaceAgentManifest)
// This route is deprecated and will be removed in a future release.
// New agents will use /me/manifest instead.
r.Get("/metadata", api.workspaceAgentManifest)
r.Post("/startup", api.postWorkspaceAgentStartup)
r.Patch("/startup-logs", api.patchWorkspaceAgentStartupLogs)
r.Post("/app-health", api.postWorkspaceAppHealth)
r.Get("/gitauth", api.workspaceAgentsGitAuth)
r.Get("/gitsshkey", api.agentGitSSHKey)
r.Get("/coordinate", api.workspaceAgentCoordinate)
r.Post("/report-stats", api.workspaceAgentReportStats)
r.Post("/report-lifecycle", api.workspaceAgentReportLifecycle)
r.Post("/metadata/{key}", api.workspaceAgentPostMetadata)
})
r.Route("/{workspaceagent}", func(r chi.Router) {
r.Use(
// Allow either API key or external workspace proxy auth and require it.
apiKeyMiddlewareOptional,
httpmw.ExtractWorkspaceProxy(httpmw.ExtractWorkspaceProxyConfig{
DB: options.Database,
Optional: true,
}),
httpmw.RequireAPIKeyOrWorkspaceProxyAuth(),
httpmw.ExtractWorkspaceAgentParam(options.Database),
httpmw.ExtractWorkspaceParam(options.Database),
)
r.Get("/", api.workspaceAgent)
r.Get("/watch-metadata", api.watchWorkspaceAgentMetadata)
r.Get("/startup-logs", api.workspaceAgentStartupLogs)
r.Get("/listening-ports", api.workspaceAgentListeningPorts)
r.Get("/connection", api.workspaceAgentConnection)
r.Get("/coordinate", api.workspaceAgentClientCoordinate)
// PTY is part of workspaceAppServer.
})
})
r.Route("/workspaces", func(r chi.Router) {
r.Use(
apiKeyMiddleware,
)
r.Get("/", api.workspaces)
r.Route("/{workspace}", func(r chi.Router) {
r.Use(
httpmw.ExtractWorkspaceParam(options.Database),
)
r.Get("/", api.workspace)
r.Patch("/", api.patchWorkspace)
r.Route("/builds", func(r chi.Router) {
r.Get("/", api.workspaceBuilds)
r.Post("/", api.postWorkspaceBuilds)
})
r.Route("/autostart", func(r chi.Router) {
r.Put("/", api.putWorkspaceAutostart)
})
r.Route("/ttl", func(r chi.Router) {
r.Put("/", api.putWorkspaceTTL)
})
r.Get("/watch", api.watchWorkspace)
r.Put("/extend", api.putExtendWorkspace)
})
})
r.Route("/workspacebuilds/{workspacebuild}", func(r chi.Router) {
r.Use(
apiKeyMiddleware,
httpmw.ExtractWorkspaceBuildParam(options.Database),
httpmw.ExtractWorkspaceParam(options.Database),
)
r.Get("/", api.workspaceBuild)
r.Patch("/cancel", api.patchCancelWorkspaceBuild)
r.Get("/logs", api.workspaceBuildLogs)
r.Get("/parameters", api.workspaceBuildParameters)
r.Get("/resources", api.workspaceBuildResources)
r.Get("/state", api.workspaceBuildState)
})
r.Route("/authcheck", func(r chi.Router) {
r.Use(apiKeyMiddleware)
r.Post("/", api.checkAuthorization)
})
r.Route("/applications", func(r chi.Router) {
r.Route("/host", func(r chi.Router) {
// Don't leak the hostname to unauthenticated users.
r.Use(apiKeyMiddleware)
r.Get("/", api.appHost)
})
r.Route("/auth-redirect", func(r chi.Router) {
// We want to redirect to login if they are not authenticated.
r.Use(apiKeyMiddlewareRedirect)
// This is a GET request as it's redirected to by the subdomain app
// handler and the login page.
r.Get("/", api.workspaceApplicationAuth)
})
})
r.Route("/insights", func(r chi.Router) {
r.Use(apiKeyMiddleware)
r.Get("/daus", api.deploymentDAUs)
})
r.Route("/debug", func(r chi.Router) {
r.Use(
apiKeyMiddleware,
// Ensure only owners can access debug endpoints.
func(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
if !api.Authorize(r, rbac.ActionRead, rbac.ResourceDebugInfo) {
httpapi.ResourceNotFound(rw)
return
}
next.ServeHTTP(rw, r)
})
},
)
r.Get("/coordinator", api.debugCoordinator)
r.Get("/health", api.debugDeploymentHealth)
r.Get("/ws", (&healthcheck.WebsocketEchoServer{}).ServeHTTP)
})
})
if options.SwaggerEndpoint {
// Swagger UI requires the URL trailing slash. Otherwise, the browser tries to load /assets
// from http://localhost:8080/assets instead of http://localhost:8080/swagger/assets.
r.Get("/swagger", http.RedirectHandler("/swagger/", http.StatusTemporaryRedirect).ServeHTTP)
// See globalHTTPSwaggerHandler comment as to why we use a package
// global variable here.
r.Get("/swagger/*", globalHTTPSwaggerHandler)
}
// Add CSP headers to all static assets and pages. CSP headers only affect
// browsers, so these don't make sense on api routes.
cspMW := httpmw.CSPHeaders(func() []string {
if api.DeploymentValues.Dangerous.AllowAllCors {
// In this mode, allow all external requests
return []string{"*"}
}
if f := api.WorkspaceProxyHostsFn.Load(); f != nil {
return (*f)()
}
// By default we do not add extra websocket connections to the CSP
return []string{}
})
r.NotFound(cspMW(compressHandler(http.HandlerFunc(api.siteHandler.ServeHTTP))).ServeHTTP)
// This must be before all middleware to improve the response time.
// So make a new router, and mount the old one as the root.
rootRouter := chi.NewRouter()
// This is the only route we add before all the middleware.
// We want to time the latency of the request, so any middleware will
// interfere with that timing.
rootRouter.Get("/latency-check", cors(LatencyCheck(options.DeploymentValues.Dangerous.AllowAllCors.Value(), api.AccessURL)).ServeHTTP)
rootRouter.Mount("/", r)
api.RootHandler = rootRouter
return api
}
type API struct {
// ctx is canceled immediately on shutdown, it can be used to abort
// interruptible tasks.
ctx context.Context
cancel context.CancelFunc
*Options
// ID is a uniquely generated ID on initialization.
// This is used to associate objects with a specific
// Coder API instance, like workspace agents to a
// specific replica.
ID uuid.UUID
Auditor atomic.Pointer[audit.Auditor]
WorkspaceClientCoordinateOverride atomic.Pointer[func(rw http.ResponseWriter) bool]
TailnetCoordinator atomic.Pointer[tailnet.Coordinator]
QuotaCommitter atomic.Pointer[proto.QuotaCommitter]
// WorkspaceProxyHostsFn returns the hosts of healthy workspace proxies
// for header reasons.
WorkspaceProxyHostsFn atomic.Pointer[func() []string]
// TemplateScheduleStore is a pointer to an atomic pointer because this is
// passed to another struct, and we want them all to be the same reference.
TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore]
HTTPAuth *HTTPAuthorizer
// APIHandler serves "/api/v2"
APIHandler chi.Router
// RootHandler serves "/"
RootHandler chi.Router
siteHandler http.Handler
WebsocketWaitMutex sync.Mutex
WebsocketWaitGroup sync.WaitGroup
derpCloseFunc func()
metricsCache *metricscache.Cache
workspaceAgentCache *wsconncache.Cache
updateChecker *updatecheck.Checker
WorkspaceAppsProvider workspaceapps.SignedTokenProvider
workspaceAppServer *workspaceapps.Server
// Experiments contains the list of experiments currently enabled.
// This is used to gate features that are not yet ready for production.
Experiments codersdk.Experiments
healthCheckGroup *singleflight.Group[string, *healthcheck.Report]
healthCheckCache atomic.Pointer[healthcheck.Report]
}
// Close waits for all WebSocket connections to drain before returning.
func (api *API) Close() error {
api.cancel()
api.derpCloseFunc()
api.WebsocketWaitMutex.Lock()
api.WebsocketWaitGroup.Wait()
api.WebsocketWaitMutex.Unlock()
api.metricsCache.Close()
if api.updateChecker != nil {
api.updateChecker.Close()
}
coordinator := api.TailnetCoordinator.Load()
if coordinator != nil {
_ = (*coordinator).Close()
}
return api.workspaceAgentCache.Close()
}
func compressHandler(h http.Handler) http.Handler {
level := 5
if flag.Lookup("test.v") != nil {
level = 1
}
cmp := middleware.NewCompressor(level,
"text/*",
"application/*",
"image/*",
)
cmp.SetEncoder("br", func(w io.Writer, level int) io.Writer {
return brotli.NewWriterLevel(w, level)
})
cmp.SetEncoder("zstd", func(w io.Writer, level int) io.Writer {
zw, err := zstd.NewWriter(w, zstd.WithEncoderLevel(zstd.EncoderLevelFromZstd(level)))
if err != nil {
panic("invalid zstd compressor: " + err.Error())
}
return zw
})
return cmp.Handler(h)
}
// CreateInMemoryProvisionerDaemon is an in-memory connection to a provisionerd.
// Useful when starting coderd and provisionerd in the same process.
func (api *API) CreateInMemoryProvisionerDaemon(ctx context.Context, debounce time.Duration) (client proto.DRPCProvisionerDaemonClient, err error) {
tracer := api.TracerProvider.Tracer(tracing.TracerName)
clientSession, serverSession := provisionersdk.MemTransportPipe()
defer func() {
if err != nil {
_ = clientSession.Close()
_ = serverSession.Close()
}
}()
name := namesgenerator.GetRandomName(1)
// nolint:gocritic // Inserting a provisioner daemon is a system function.
daemon, err := api.Database.InsertProvisionerDaemon(dbauthz.AsSystemRestricted(ctx), database.InsertProvisionerDaemonParams{
ID: uuid.New(),
CreatedAt: database.Now(),
Name: name,
Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho, database.ProvisionerTypeTerraform},
Tags: dbtype.StringMap{
provisionerdserver.TagScope: provisionerdserver.ScopeOrganization,
},
})
if err != nil {
return nil, xerrors.Errorf("insert provisioner daemon %q: %w", name, err)
}
tags, err := json.Marshal(daemon.Tags)
if err != nil {
return nil, xerrors.Errorf("marshal tags: %w", err)
}
mux := drpcmux.New()
err = proto.DRPCRegisterProvisionerDaemon(mux, &provisionerdserver.Server{
AccessURL: api.AccessURL,
ID: daemon.ID,
OIDCConfig: api.OIDCConfig,
Database: api.Database,
Pubsub: api.Pubsub,
Provisioners: daemon.Provisioners,
GitAuthConfigs: api.GitAuthConfigs,
Telemetry: api.Telemetry,
Tracer: tracer,
Tags: tags,
QuotaCommitter: &api.QuotaCommitter,
Auditor: &api.Auditor,
TemplateScheduleStore: api.TemplateScheduleStore,
AcquireJobDebounce: debounce,
Logger: api.Logger.Named(fmt.Sprintf("provisionerd-%s", daemon.Name)),
DeploymentValues: api.DeploymentValues,
})
if err != nil {
return nil, err
}
server := drpcserver.NewWithOptions(&tracing.DRPCHandler{Handler: mux},
drpcserver.Options{
Log: func(err error) {
if xerrors.Is(err, io.EOF) {
return
}
api.Logger.Debug(ctx, "drpc server error", slog.Error(err))
},
},
)
go func() {
err := server.Serve(ctx, serverSession)
if err != nil && !xerrors.Is(err, io.EOF) {
api.Logger.Debug(ctx, "provisioner daemon disconnected", slog.Error(err))
}
// close the sessions so we don't leak goroutines serving them.
_ = clientSession.Close()
_ = serverSession.Close()
}()
return proto.NewDRPCProvisionerDaemonClient(clientSession), nil
}
// nolint:revive
func initExperiments(log slog.Logger, raw []string) codersdk.Experiments {
exps := make([]codersdk.Experiment, 0, len(raw))
for _, v := range raw {
switch v {
case "*":
exps = append(exps, codersdk.ExperimentsAll...)
default:
ex := codersdk.Experiment(strings.ToLower(v))
if !slice.Contains(codersdk.ExperimentsAll, ex) {
log.Warn(context.Background(), "🐉 HERE BE DRAGONS: opting into hidden experiment", slog.F("experiment", ex))
}
exps = append(exps, ex)
}
}
return exps
}