mirror of
https://github.com/coder/coder.git
synced 2025-07-03 16:13:58 +00:00
Currently, importing `codersdk` just to interact with the API requires importing tailscale, which causes builds to fail unless manually using our fork.
1474 lines
53 KiB
Go
1474 lines
53 KiB
Go
package coderd
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"database/sql"
|
|
"expvar"
|
|
"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/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"
|
|
agentproto "github.com/coder/coder/v2/agent/proto"
|
|
"github.com/coder/coder/v2/buildinfo"
|
|
_ "github.com/coder/coder/v2/coderd/apidoc" // Used for swagger docs.
|
|
"github.com/coder/coder/v2/coderd/appearance"
|
|
"github.com/coder/coder/v2/coderd/audit"
|
|
"github.com/coder/coder/v2/coderd/awsidentity"
|
|
"github.com/coder/coder/v2/coderd/batchstats"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
|
"github.com/coder/coder/v2/coderd/database/dbrollup"
|
|
"github.com/coder/coder/v2/coderd/database/dbtime"
|
|
"github.com/coder/coder/v2/coderd/database/pubsub"
|
|
"github.com/coder/coder/v2/coderd/externalauth"
|
|
"github.com/coder/coder/v2/coderd/gitsshkey"
|
|
"github.com/coder/coder/v2/coderd/healthcheck"
|
|
"github.com/coder/coder/v2/coderd/healthcheck/derphealth"
|
|
"github.com/coder/coder/v2/coderd/httpapi"
|
|
"github.com/coder/coder/v2/coderd/httpmw"
|
|
"github.com/coder/coder/v2/coderd/metricscache"
|
|
"github.com/coder/coder/v2/coderd/portsharing"
|
|
"github.com/coder/coder/v2/coderd/prometheusmetrics"
|
|
"github.com/coder/coder/v2/coderd/provisionerdserver"
|
|
"github.com/coder/coder/v2/coderd/rbac"
|
|
"github.com/coder/coder/v2/coderd/schedule"
|
|
"github.com/coder/coder/v2/coderd/telemetry"
|
|
"github.com/coder/coder/v2/coderd/tracing"
|
|
"github.com/coder/coder/v2/coderd/updatecheck"
|
|
"github.com/coder/coder/v2/coderd/util/slice"
|
|
"github.com/coder/coder/v2/coderd/workspaceapps"
|
|
"github.com/coder/coder/v2/coderd/workspaceusage"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/codersdk/drpc"
|
|
"github.com/coder/coder/v2/codersdk/healthsdk"
|
|
"github.com/coder/coder/v2/provisionerd/proto"
|
|
"github.com/coder/coder/v2/provisionersdk"
|
|
"github.com/coder/coder/v2/site"
|
|
"github.com/coder/coder/v2/tailnet"
|
|
"github.com/coder/serpent"
|
|
)
|
|
|
|
// 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"))
|
|
}
|
|
|
|
var expDERPOnce = sync.Once{}
|
|
|
|
// 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" or "*.apps.coder.com:8080".
|
|
AppHostname string
|
|
// AppHostnameRegex contains the regex version of options.AppHostname as
|
|
// generated by appurl.CompileHostnamePattern(). It MUST be set if
|
|
// options.AppHostname is set.
|
|
AppHostnameRegex *regexp.Regexp
|
|
Logger slog.Logger
|
|
Database database.Store
|
|
Pubsub pubsub.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
|
|
ExternalAuthConfigs []*externalauth.Config
|
|
RealIPConfig *httpmw.RealIPConfig
|
|
TrialGenerator func(ctx context.Context, body codersdk.LicensorTrialRequest) error
|
|
// RefreshEntitlements is used to set correct entitlements after creating first user and generating trial license.
|
|
RefreshEntitlements func(ctx context.Context) error
|
|
// PostAuthAdditionalHeadersFunc is used to add additional headers to the response
|
|
// after a successful authentication.
|
|
// This is somewhat janky, but seemingly the only reasonable way to add a header
|
|
// for all authenticated users under a condition, only in Enterprise.
|
|
PostAuthAdditionalHeadersFunc func(auth httpmw.Authorization, header http.Header)
|
|
|
|
// TLSCertificates is used to mesh DERP servers securely.
|
|
TLSCertificates []tls.Certificate
|
|
TailnetCoordinator tailnet.Coordinator
|
|
DERPServer *derp.Server
|
|
// BaseDERPMap is used as the base DERP map for all clients and agents.
|
|
// Proxies are added to this list.
|
|
BaseDERPMap *tailcfg.DERPMap
|
|
DERPMapUpdateFrequency time.Duration
|
|
SwaggerEndpoint bool
|
|
SetUserGroups func(ctx context.Context, logger slog.Logger, tx database.Store, userID uuid.UUID, orgGroupNames map[uuid.UUID][]string, createMissingGroups bool) error
|
|
SetUserSiteRoles func(ctx context.Context, logger slog.Logger, tx database.Store, userID uuid.UUID, roles []string) error
|
|
TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore]
|
|
UserQuietHoursScheduleStore *atomic.Pointer[schedule.UserQuietHoursScheduleStore]
|
|
AccessControlStore *atomic.Pointer[dbauthz.AccessControlStore]
|
|
// 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) *healthsdk.HealthcheckReport
|
|
HealthcheckTimeout time.Duration
|
|
HealthcheckRefresh time.Duration
|
|
WorkspaceProxiesFetchUpdater *atomic.Pointer[healthcheck.WorkspaceProxiesFetchUpdater]
|
|
|
|
// OAuthSigningKey is the crypto key used to sign and encrypt state strings
|
|
// related to OAuth. This is a symmetric secret key using hmac to sign payloads.
|
|
// So this secret should **never** be exposed to the client.
|
|
OAuthSigningKey [32]byte
|
|
|
|
// 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
|
|
// DeploymentOptions do contain the copy of DeploymentValues, and contain
|
|
// contextual information about how the values were set.
|
|
// Do not use DeploymentOptions to retrieve values, use DeploymentValues instead.
|
|
// All secrets values are stripped.
|
|
DeploymentOptions serpent.OptionSet
|
|
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, labels prometheusmetrics.AgentMetricLabels, metrics []*agentproto.Stats_Metric)
|
|
StatsBatcher *batchstats.Batcher
|
|
|
|
WorkspaceAppsStatsCollectorOptions workspaceapps.StatsCollectorOptions
|
|
|
|
// This janky function is used in telemetry to parse fields out of the raw
|
|
// JWT. It needs to be passed through like this because license parsing is
|
|
// under the enterprise license, and can't be imported into AGPL.
|
|
ParseLicenseClaims func(rawJWT string) (email string, trial bool, err error)
|
|
AllowWorkspaceRenames bool
|
|
|
|
// NewTicker is used for unit tests to replace "time.NewTicker".
|
|
NewTicker func(duration time.Duration) (tick <-chan time.Time, done func())
|
|
|
|
// DatabaseRolluper rolls up template usage stats from raw agent and app
|
|
// stats. This is used to provide insights in the WebUI.
|
|
DatabaseRolluper *dbrollup.Rolluper
|
|
// WorkspaceUsageTracker tracks workspace usage by the CLI.
|
|
WorkspaceUsageTracker *workspaceusage.Tracker
|
|
}
|
|
|
|
// @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{}
|
|
}
|
|
if options.NewTicker == nil {
|
|
options.NewTicker = func(duration time.Duration) (tick <-chan time.Time, done func()) {
|
|
ticker := time.NewTicker(duration)
|
|
return ticker.C, ticker.Stop
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
if options.AccessControlStore == nil {
|
|
options.AccessControlStore = &atomic.Pointer[dbauthz.AccessControlStore]{}
|
|
var tacs dbauthz.AccessControlStore = dbauthz.AGPLTemplateAccessControlStore{}
|
|
options.AccessControlStore.Store(&tacs)
|
|
}
|
|
|
|
options.Database = dbauthz.New(
|
|
options.Database,
|
|
options.Authorizer,
|
|
options.Logger.Named("authz_querier"),
|
|
options.AccessControlStore,
|
|
)
|
|
|
|
experiments := ReadExperiments(
|
|
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.DERPServer == nil && options.DeploymentValues.DERP.Server.Enable {
|
|
options.DERPServer = derp.NewServer(key.NewNode(), tailnet.Logger(options.Logger.Named("derp")))
|
|
}
|
|
if options.DERPMapUpdateFrequency == 0 {
|
|
options.DERPMapUpdateFrequency = 5 * time.Second
|
|
}
|
|
if options.TailnetCoordinator == nil {
|
|
options.TailnetCoordinator = tailnet.NewCoordinator(options.Logger)
|
|
}
|
|
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, logger slog.Logger, _ database.Store, userID uuid.UUID, orgGroupNames map[uuid.UUID][]string, createMissingGroups bool) error {
|
|
logger.Warn(ctx, "attempted to assign OIDC groups without enterprise license",
|
|
slog.F("user_id", userID),
|
|
slog.F("groups", orgGroupNames),
|
|
slog.F("create_missing_groups", createMissingGroups),
|
|
)
|
|
return nil
|
|
}
|
|
}
|
|
if options.SetUserSiteRoles == nil {
|
|
options.SetUserSiteRoles = func(ctx context.Context, logger slog.Logger, _ database.Store, userID uuid.UUID, roles []string) error {
|
|
logger.Warn(ctx, "attempted to assign OIDC user roles without enterprise license",
|
|
slog.F("user_id", userID), slog.F("roles", roles),
|
|
)
|
|
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.UserQuietHoursScheduleStore == nil {
|
|
options.UserQuietHoursScheduleStore = &atomic.Pointer[schedule.UserQuietHoursScheduleStore]{}
|
|
}
|
|
if options.UserQuietHoursScheduleStore.Load() == nil {
|
|
v := schedule.NewAGPLUserQuietHoursScheduleStore()
|
|
options.UserQuietHoursScheduleStore.Store(&v)
|
|
}
|
|
|
|
if options.StatsBatcher == nil {
|
|
panic("developer error: options.StatsBatcher is nil")
|
|
}
|
|
|
|
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,
|
|
},
|
|
)
|
|
|
|
oauthConfigs := &httpmw.OAuth2Configs{
|
|
Github: options.GithubOAuth2Config,
|
|
OIDC: options.OIDCConfig,
|
|
}
|
|
|
|
if options.DatabaseRolluper == nil {
|
|
options.DatabaseRolluper = dbrollup.New(options.Logger.Named("dbrollup"), options.Database)
|
|
}
|
|
|
|
if options.WorkspaceUsageTracker == nil {
|
|
options.WorkspaceUsageTracker = workspaceusage.New(options.Database,
|
|
workspaceusage.WithLogger(options.Logger.Named("workspace_usage_tracker")),
|
|
)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
r := chi.NewRouter()
|
|
|
|
// nolint:gocritic // Load deployment ID. This never changes
|
|
depID, err := options.Database.GetDeploymentID(dbauthz.AsSystemRestricted(ctx))
|
|
if err != nil {
|
|
panic(xerrors.Errorf("get deployment ID: %w", err))
|
|
}
|
|
api := &API{
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
DeploymentID: depID,
|
|
|
|
ID: uuid.New(),
|
|
Options: options,
|
|
RootHandler: r,
|
|
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]{},
|
|
TailnetCoordinator: atomic.Pointer[tailnet.Coordinator]{},
|
|
TemplateScheduleStore: options.TemplateScheduleStore,
|
|
UserQuietHoursScheduleStore: options.UserQuietHoursScheduleStore,
|
|
AccessControlStore: options.AccessControlStore,
|
|
Experiments: experiments,
|
|
healthCheckGroup: &singleflight.Group[string, *healthsdk.HealthcheckReport]{},
|
|
Acquirer: provisionerdserver.NewAcquirer(
|
|
ctx,
|
|
options.Logger.Named("acquirer"),
|
|
options.Database,
|
|
options.Pubsub,
|
|
),
|
|
dbRolluper: options.DatabaseRolluper,
|
|
workspaceUsageTracker: options.WorkspaceUsageTracker,
|
|
}
|
|
|
|
api.AppearanceFetcher.Store(&appearance.DefaultFetcher)
|
|
api.PortSharer.Store(&portsharing.DefaultPortSharer)
|
|
api.SiteHandler = site.New(&site.Options{
|
|
BinFS: binFS,
|
|
BinHashes: binHashes,
|
|
Database: options.Database,
|
|
SiteFS: site.FS(),
|
|
OAuth2Configs: oauthConfigs,
|
|
DocsURL: options.DeploymentValues.DocsURL.String(),
|
|
AppearanceFetcher: &api.AppearanceFetcher,
|
|
})
|
|
api.SiteHandler.Experiments.Store(&experiments)
|
|
|
|
if options.UpdateCheckOptions != nil {
|
|
api.updateChecker = updatecheck.New(
|
|
options.Database,
|
|
options.Logger.Named("update_checker"),
|
|
*options.UpdateCheckOptions,
|
|
)
|
|
}
|
|
|
|
if options.WorkspaceProxiesFetchUpdater == nil {
|
|
options.WorkspaceProxiesFetchUpdater = &atomic.Pointer[healthcheck.WorkspaceProxiesFetchUpdater]{}
|
|
var wpfu healthcheck.WorkspaceProxiesFetchUpdater = &healthcheck.AGPLWorkspaceProxiesFetchUpdater{}
|
|
options.WorkspaceProxiesFetchUpdater.Store(&wpfu)
|
|
}
|
|
|
|
if options.HealthcheckFunc == nil {
|
|
options.HealthcheckFunc = func(ctx context.Context, apiKey string) *healthsdk.HealthcheckReport {
|
|
// NOTE: dismissed healthchecks are marked in formatHealthcheck.
|
|
// Not here, as this result gets cached.
|
|
return healthcheck.Run(ctx, &healthcheck.ReportOptions{
|
|
Database: healthcheck.DatabaseReportOptions{
|
|
DB: options.Database,
|
|
Threshold: options.DeploymentValues.Healthcheck.ThresholdDatabase.Value(),
|
|
},
|
|
Websocket: healthcheck.WebsocketReportOptions{
|
|
AccessURL: options.AccessURL,
|
|
APIKey: apiKey,
|
|
},
|
|
AccessURL: healthcheck.AccessURLReportOptions{
|
|
AccessURL: options.AccessURL,
|
|
},
|
|
DerpHealth: derphealth.ReportOptions{
|
|
DERPMap: api.DERPMap(),
|
|
},
|
|
WorkspaceProxy: healthcheck.WorkspaceProxyReportOptions{
|
|
WorkspaceProxiesFetchUpdater: *(options.WorkspaceProxiesFetchUpdater).Load(),
|
|
},
|
|
ProvisionerDaemons: healthcheck.ProvisionerDaemonsReportDeps{
|
|
CurrentVersion: buildinfo.Version(),
|
|
CurrentAPIMajorVersion: proto.CurrentMajor,
|
|
Store: options.Database,
|
|
// TimeNow and StaleInterval set to defaults, see healthcheck/provisioner.go
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
if options.HealthcheckTimeout == 0 {
|
|
options.HealthcheckTimeout = 30 * time.Second
|
|
}
|
|
if options.HealthcheckRefresh == 0 {
|
|
options.HealthcheckRefresh = options.DeploymentValues.Healthcheck.Refresh.Value()
|
|
}
|
|
|
|
var oidcAuthURLParams map[string]string
|
|
if options.OIDCConfig != nil {
|
|
oidcAuthURLParams = options.OIDCConfig.AuthURLParams
|
|
}
|
|
|
|
api.Auditor.Store(&options.Auditor)
|
|
api.TailnetCoordinator.Store(&options.TailnetCoordinator)
|
|
stn, err := NewServerTailnet(api.ctx,
|
|
options.Logger,
|
|
options.DERPServer,
|
|
api.DERPMap,
|
|
options.DeploymentValues.DERP.Config.ForceWebSockets.Value(),
|
|
func(context.Context) (tailnet.MultiAgentConn, error) {
|
|
return (*api.TailnetCoordinator.Load()).ServeMultiAgent(uuid.New()), nil
|
|
},
|
|
options.DeploymentValues.DERP.Config.BlockDirect.Value(),
|
|
api.TracerProvider,
|
|
)
|
|
if err != nil {
|
|
panic("failed to setup server tailnet: " + err.Error())
|
|
}
|
|
api.agentProvider = stn
|
|
if options.DeploymentValues.Prometheus.Enable {
|
|
options.PrometheusRegistry.MustRegister(stn)
|
|
}
|
|
api.TailnetClientService, err = tailnet.NewClientService(
|
|
api.Logger.Named("tailnetclient"),
|
|
&api.TailnetCoordinator,
|
|
api.Options.DERPMapUpdateFrequency,
|
|
api.DERPMap,
|
|
)
|
|
if err != nil {
|
|
api.Logger.Fatal(api.ctx, "failed to initialize tailnet client service", slog.Error(err))
|
|
}
|
|
|
|
workspaceAppsLogger := options.Logger.Named("workspaceapps")
|
|
if options.WorkspaceAppsStatsCollectorOptions.Logger == nil {
|
|
named := workspaceAppsLogger.Named("stats_collector")
|
|
options.WorkspaceAppsStatsCollectorOptions.Logger = &named
|
|
}
|
|
if options.WorkspaceAppsStatsCollectorOptions.Reporter == nil {
|
|
options.WorkspaceAppsStatsCollectorOptions.Reporter = workspaceapps.NewStatsDBReporter(options.Database, workspaceapps.DefaultStatsDBReporterBatchSize)
|
|
}
|
|
|
|
api.workspaceAppServer = &workspaceapps.Server{
|
|
Logger: workspaceAppsLogger,
|
|
|
|
DashboardURL: api.AccessURL,
|
|
AccessURL: api.AccessURL,
|
|
Hostname: api.AppHostname,
|
|
HostnameRegex: api.AppHostnameRegex,
|
|
RealIPConfig: options.RealIPConfig,
|
|
|
|
SignedTokenProvider: api.WorkspaceAppsProvider,
|
|
AgentProvider: api.agentProvider,
|
|
AppSecurityKey: options.AppSecurityKey,
|
|
StatsCollector: workspaceapps.NewStatsCollector(options.WorkspaceAppsStatsCollectorOptions),
|
|
|
|
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,
|
|
SessionTokenFunc: nil, // Default behavior
|
|
PostAuthAdditionalHeadersFunc: options.PostAuthAdditionalHeadersFunc,
|
|
})
|
|
// 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,
|
|
SessionTokenFunc: nil, // Default behavior
|
|
PostAuthAdditionalHeadersFunc: options.PostAuthAdditionalHeadersFunc,
|
|
})
|
|
// 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,
|
|
SessionTokenFunc: nil, // Default behavior
|
|
PostAuthAdditionalHeadersFunc: options.PostAuthAdditionalHeadersFunc,
|
|
})
|
|
|
|
// 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)
|
|
|
|
// Register DERP on expvar HTTP handler, which we serve below in the router, c.f. expvar.Handler()
|
|
// These are the metrics the DERP server exposes.
|
|
// TODO: export via prometheus
|
|
expDERPOnce.Do(func() {
|
|
// We need to do this via a global Once because expvar registry is global and panics if we
|
|
// register multiple times. In production there is only one Coderd and one DERP server per
|
|
// process, but in testing, we create multiple of both, so the Once protects us from
|
|
// panicking.
|
|
if options.DERPServer != nil {
|
|
expvar.Publish("derp", api.DERPServer.ExpVar())
|
|
}
|
|
})
|
|
cors := httpmw.Cors(options.DeploymentValues.Dangerous.AllowAllCors.Value())
|
|
prometheusMW := httpmw.Prometheus(options.PrometheusRegistry)
|
|
|
|
api.statsBatcher = options.StatsBatcher
|
|
|
|
r.Use(
|
|
httpmw.Recover(api.Logger),
|
|
tracing.StatusWriterMiddleware,
|
|
tracing.Middleware(api.TracerProvider),
|
|
httpmw.AttachRequestID,
|
|
httpmw.ExtractRealIP(api.RealIPConfig),
|
|
httpmw.Logger(api.Logger),
|
|
prometheusMW,
|
|
// 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(codersdk.BuildVersionHeader, buildinfo.Version())
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
},
|
|
// 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 CORS and must be BEFORE the auth
|
|
// and CORS middleware.
|
|
api.workspaceAppServer.HandleSubdomain(apiRateLimiter),
|
|
cors,
|
|
// 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)
|
|
})
|
|
|
|
if options.DERPServer != nil {
|
|
derpHandler := derphttp.Handler(api.DERPServer)
|
|
derpHandler, api.derpCloseFunc = tailnet.WithWebsocketSupport(api.DERPServer, derpHandler)
|
|
|
|
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)
|
|
})
|
|
})
|
|
}
|
|
|
|
// Register callback handlers for each OAuth2 provider.
|
|
// We must support gitauth and externalauth for backwards compatibility.
|
|
for _, route := range []string{"gitauth", "external-auth"} {
|
|
r.Route("/"+route, func(r chi.Router) {
|
|
for _, externalAuthConfig := range options.ExternalAuthConfigs {
|
|
// We don't need to register a callback handler for device auth.
|
|
if externalAuthConfig.DeviceAuth != nil {
|
|
continue
|
|
}
|
|
r.Route(fmt.Sprintf("/%s/callback", externalAuthConfig.ID), func(r chi.Router) {
|
|
r.Use(
|
|
apiKeyMiddlewareRedirect,
|
|
httpmw.ExtractOAuth2(externalAuthConfig, options.HTTPClient, nil),
|
|
)
|
|
r.Get("/", api.externalAuthCallback(externalAuthConfig))
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
// OAuth2 linking routes do not make sense under the /api/v2 path. These are
|
|
// for an external application to use Coder as an OAuth2 provider, not for
|
|
// logging into Coder with an external OAuth2 provider.
|
|
r.Route("/oauth2", func(r chi.Router) {
|
|
r.Use(
|
|
api.oAuth2ProviderMiddleware,
|
|
// Fetch the app as system because in the /tokens route there will be no
|
|
// authenticated user.
|
|
httpmw.AsAuthzSystem(httpmw.ExtractOAuth2ProviderApp(options.Database)),
|
|
)
|
|
r.Route("/authorize", func(r chi.Router) {
|
|
r.Use(apiKeyMiddlewareRedirect)
|
|
r.Get("/", api.getOAuth2ProviderAppAuthorize())
|
|
})
|
|
r.Route("/tokens", func(r chi.Router) {
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(apiKeyMiddleware)
|
|
// DELETE on /tokens is not part of the OAuth2 spec. It is our own
|
|
// route used to revoke permissions from an application. It is here for
|
|
// parity with POST on /tokens.
|
|
r.Delete("/", api.deleteOAuth2ProviderAppTokens())
|
|
})
|
|
// The POST /tokens endpoint will be called from an unauthorized client so
|
|
// we cannot require an API key.
|
|
r.Post("/", api.postOAuth2ProviderAppToken())
|
|
})
|
|
})
|
|
|
|
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, api.DeploymentValues.CLIUpgradeMessage.String()))
|
|
// /regions is overridden in the enterprise version
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(apiKeyMiddleware)
|
|
r.Get("/regions", api.regions)
|
|
})
|
|
r.Route("/derp-map", func(r chi.Router) {
|
|
// r.Use(apiKeyMiddleware)
|
|
r.Get("/", api.derpMapUpdates)
|
|
})
|
|
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("/available", handleExperimentsSafe)
|
|
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("/external-auth", func(r chi.Router) {
|
|
r.Use(
|
|
apiKeyMiddleware,
|
|
)
|
|
// Get without a specific external auth ID will return all external auths.
|
|
r.Get("/", api.listUserExternalAuths)
|
|
r.Route("/{externalauth}", func(r chi.Router) {
|
|
r.Use(
|
|
httpmw.ExtractExternalAuthParam(options.ExternalAuthConfigs),
|
|
)
|
|
r.Delete("/", api.deleteExternalAuthByID)
|
|
r.Get("/", api.externalAuthByID)
|
|
r.Post("/device", api.postExternalAuthDeviceByID)
|
|
r.Get("/device", api.externalAuthDeviceByID)
|
|
})
|
|
})
|
|
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.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.Post("/archive", api.postArchiveTemplateVersions)
|
|
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)
|
|
r.Post("/archive", api.postArchiveTemplateVersion())
|
|
r.Post("/unarchive", api.postUnarchiveTemplateVersion())
|
|
// 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("/external-auth", api.templateVersionExternalAuth)
|
|
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))
|
|
r.Post("/convert-login", api.postConvertLoginType)
|
|
r.Delete("/", api.deleteUser)
|
|
r.Get("/", api.userByName)
|
|
r.Get("/autofill-parameters", api.userAutofillParameters)
|
|
r.Get("/login-type", api.userLoginType)
|
|
r.Put("/profile", api.putUserProfile)
|
|
r.Route("/status", func(r chi.Router) {
|
|
r.Put("/suspend", api.putSuspendUserAccount())
|
|
r.Put("/activate", api.putActivateUserAccount())
|
|
})
|
|
r.Put("/appearance", api.putUserAppearanceSettings)
|
|
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.With(
|
|
apiKeyMiddlewareOptional,
|
|
httpmw.ExtractWorkspaceProxy(httpmw.ExtractWorkspaceProxyConfig{
|
|
DB: options.Database,
|
|
Optional: true,
|
|
}),
|
|
httpmw.RequireAPIKeyOrWorkspaceProxyAuth(),
|
|
).Get("/connection", api.workspaceAgentConnectionGeneric)
|
|
r.Route("/me", func(r chi.Router) {
|
|
r.Use(httpmw.ExtractWorkspaceAgentAndLatestBuild(httpmw.ExtractWorkspaceAgentAndLatestBuildConfig{
|
|
DB: options.Database,
|
|
Optional: false,
|
|
}))
|
|
r.Get("/rpc", api.workspaceAgentRPC)
|
|
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.patchWorkspaceAgentLogsDeprecated)
|
|
r.Patch("/logs", api.patchWorkspaceAgentLogs)
|
|
r.Post("/app-health", api.postWorkspaceAppHealth)
|
|
// Deprecated: Required to support legacy agents
|
|
r.Get("/gitauth", api.workspaceAgentsGitAuth)
|
|
r.Get("/external-auth", api.workspaceAgentsExternalAuth)
|
|
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", api.workspaceAgentPostMetadata)
|
|
r.Post("/metadata/{key}", api.workspaceAgentPostMetadataDeprecated)
|
|
})
|
|
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.workspaceAgentLogsDeprecated)
|
|
r.Get("/logs", api.workspaceAgentLogs)
|
|
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.Post("/usage", api.postWorkspaceUsage)
|
|
r.Put("/dormant", api.putWorkspaceDormant)
|
|
r.Put("/favorite", api.putFavoriteWorkspace)
|
|
r.Delete("/favorite", api.deleteFavoriteWorkspace)
|
|
r.Put("/autoupdates", api.putWorkspaceAutoupdates)
|
|
r.Get("/resolve-autostart", api.resolveAutostart)
|
|
r.Route("/port-share", func(r chi.Router) {
|
|
r.Use(
|
|
httpmw.RequireExperiment(api.Experiments, codersdk.ExperimentSharedPorts),
|
|
)
|
|
r.Get("/", api.workspaceAgentPortShares)
|
|
r.Post("/", api.postWorkspaceAgentPortShare)
|
|
r.Delete("/", api.deleteWorkspaceAgentPortShare)
|
|
})
|
|
})
|
|
})
|
|
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.workspaceBuildResourcesDeprecated)
|
|
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.Get("/user-activity", api.insightsUserActivity)
|
|
r.Get("/user-latency", api.insightsUserLatency)
|
|
r.Get("/templates", api.insightsTemplates)
|
|
})
|
|
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("/tailnet", api.debugTailnet)
|
|
r.Route("/health", func(r chi.Router) {
|
|
r.Get("/", api.debugDeploymentHealth)
|
|
r.Route("/settings", func(r chi.Router) {
|
|
r.Get("/", api.deploymentHealthSettings)
|
|
r.Put("/", api.putDeploymentHealthSettings)
|
|
})
|
|
})
|
|
r.Get("/ws", (&healthcheck.WebsocketEchoServer{}).ServeHTTP)
|
|
r.Route("/{user}", func(r chi.Router) {
|
|
r.Use(httpmw.ExtractUserParam(options.Database))
|
|
r.Get("/debug-link", api.userDebugOIDC)
|
|
})
|
|
if options.DERPServer != nil {
|
|
r.Route("/derp", func(r chi.Router) {
|
|
r.Get("/traffic", options.DERPServer.ServeDebugTraffic)
|
|
})
|
|
}
|
|
r.Method("GET", "/expvar", expvar.Handler()) // contains DERP metrics as well as cmdline and memstats
|
|
})
|
|
// Manage OAuth2 applications that can use Coder as an OAuth2 provider.
|
|
r.Route("/oauth2-provider", func(r chi.Router) {
|
|
r.Use(
|
|
apiKeyMiddleware,
|
|
api.oAuth2ProviderMiddleware,
|
|
)
|
|
r.Route("/apps", func(r chi.Router) {
|
|
r.Get("/", api.oAuth2ProviderApps)
|
|
r.Post("/", api.postOAuth2ProviderApp)
|
|
|
|
r.Route("/{app}", func(r chi.Router) {
|
|
r.Use(httpmw.ExtractOAuth2ProviderApp(options.Database))
|
|
r.Get("/", api.oAuth2ProviderApp)
|
|
r.Put("/", api.putOAuth2ProviderApp)
|
|
r.Delete("/", api.deleteOAuth2ProviderApp)
|
|
|
|
r.Route("/secrets", func(r chi.Router) {
|
|
r.Get("/", api.oAuth2ProviderAppSecrets)
|
|
r.Post("/", api.postOAuth2ProviderAppSecret)
|
|
|
|
r.Route("/{secretID}", func(r chi.Router) {
|
|
r.Use(httpmw.ExtractOAuth2ProviderAppSecret(options.Database))
|
|
r.Delete("/", api.deleteOAuth2ProviderAppSecret)
|
|
})
|
|
})
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
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)
|
|
} else {
|
|
swaggerDisabled := http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
|
httpapi.Write(context.Background(), rw, http.StatusNotFound, codersdk.Response{
|
|
Message: "Swagger documentation is disabled.",
|
|
})
|
|
})
|
|
r.Get("/swagger", swaggerDisabled)
|
|
r.Get("/swagger/*", swaggerDisabled)
|
|
}
|
|
|
|
// 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{}
|
|
})
|
|
|
|
// 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.
|
|
r.NotFound(cspMW(compressHandler(httpmw.HSTS(api.SiteHandler, options.StrictTransportSecurityCfg))).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", tracing.StatusWriterMiddleware(prometheusMW(LatencyCheck())).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
|
|
|
|
// DeploymentID is loaded from the database on startup.
|
|
DeploymentID string
|
|
|
|
*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]
|
|
TailnetClientService *tailnet.ClientService
|
|
QuotaCommitter atomic.Pointer[proto.QuotaCommitter]
|
|
AppearanceFetcher atomic.Pointer[appearance.Fetcher]
|
|
// 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]
|
|
// UserQuietHoursScheduleStore is a pointer to an atomic pointer for the
|
|
// same reason as TemplateScheduleStore.
|
|
UserQuietHoursScheduleStore *atomic.Pointer[schedule.UserQuietHoursScheduleStore]
|
|
// DERPMapper mutates the DERPMap to include workspace proxies.
|
|
DERPMapper atomic.Pointer[func(derpMap *tailcfg.DERPMap) *tailcfg.DERPMap]
|
|
// AccessControlStore is a pointer to an atomic pointer since it is
|
|
// passed to dbauthz.
|
|
AccessControlStore *atomic.Pointer[dbauthz.AccessControlStore]
|
|
PortSharer atomic.Pointer[portsharing.PortSharer]
|
|
|
|
HTTPAuth *HTTPAuthorizer
|
|
|
|
// APIHandler serves "/api/v2"
|
|
APIHandler chi.Router
|
|
// RootHandler serves "/"
|
|
RootHandler chi.Router
|
|
|
|
// SiteHandler serves static files for the dashboard.
|
|
SiteHandler *site.Handler
|
|
|
|
WebsocketWaitMutex sync.Mutex
|
|
WebsocketWaitGroup sync.WaitGroup
|
|
derpCloseFunc func()
|
|
|
|
metricsCache *metricscache.Cache
|
|
updateChecker *updatecheck.Checker
|
|
WorkspaceAppsProvider workspaceapps.SignedTokenProvider
|
|
workspaceAppServer *workspaceapps.Server
|
|
agentProvider workspaceapps.AgentProvider
|
|
|
|
// 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, *healthsdk.HealthcheckReport]
|
|
healthCheckCache atomic.Pointer[healthsdk.HealthcheckReport]
|
|
|
|
statsBatcher *batchstats.Batcher
|
|
|
|
Acquirer *provisionerdserver.Acquirer
|
|
// dbRolluper rolls up template usage stats from raw agent and app
|
|
// stats. This is used to provide insights in the WebUI.
|
|
dbRolluper *dbrollup.Rolluper
|
|
workspaceUsageTracker *workspaceusage.Tracker
|
|
}
|
|
|
|
// Close waits for all WebSocket connections to drain before returning.
|
|
func (api *API) Close() error {
|
|
api.cancel()
|
|
if api.derpCloseFunc != nil {
|
|
api.derpCloseFunc()
|
|
}
|
|
|
|
wsDone := make(chan struct{})
|
|
timer := time.NewTimer(10 * time.Second)
|
|
defer timer.Stop()
|
|
go func() {
|
|
api.WebsocketWaitMutex.Lock()
|
|
defer api.WebsocketWaitMutex.Unlock()
|
|
api.WebsocketWaitGroup.Wait()
|
|
close(wsDone)
|
|
}()
|
|
// This will technically leak the above func if the timer fires, but this is
|
|
// maintly a last ditch effort to un-stuck coderd on shutdown. This
|
|
// shouldn't affect tests at all.
|
|
select {
|
|
case <-wsDone:
|
|
case <-timer.C:
|
|
api.Logger.Warn(api.ctx, "websocket shutdown timed out after 10 seconds")
|
|
}
|
|
|
|
api.dbRolluper.Close()
|
|
api.metricsCache.Close()
|
|
if api.updateChecker != nil {
|
|
api.updateChecker.Close()
|
|
}
|
|
_ = api.workspaceAppServer.Close()
|
|
coordinator := api.TailnetCoordinator.Load()
|
|
if coordinator != nil {
|
|
_ = (*coordinator).Close()
|
|
}
|
|
_ = api.agentProvider.Close()
|
|
api.workspaceUsageTracker.Close()
|
|
return nil
|
|
}
|
|
|
|
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(dialCtx context.Context, name string) (client proto.DRPCProvisionerDaemonClient, err error) {
|
|
tracer := api.TracerProvider.Tracer(tracing.TracerName)
|
|
clientSession, serverSession := drpc.MemTransportPipe()
|
|
defer func() {
|
|
if err != nil {
|
|
_ = clientSession.Close()
|
|
_ = serverSession.Close()
|
|
}
|
|
}()
|
|
|
|
// All in memory provisioners will be part of the default org for now.
|
|
//nolint:gocritic // in-memory provisioners are owned by system
|
|
defaultOrg, err := api.Database.GetDefaultOrganization(dbauthz.AsSystemRestricted(dialCtx))
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("unable to fetch default org for in memory provisioner: %w", err)
|
|
}
|
|
|
|
//nolint:gocritic // in-memory provisioners are owned by system
|
|
daemon, err := api.Database.UpsertProvisionerDaemon(dbauthz.AsSystemRestricted(dialCtx), database.UpsertProvisionerDaemonParams{
|
|
Name: name,
|
|
OrganizationID: defaultOrg.ID,
|
|
CreatedAt: dbtime.Now(),
|
|
Provisioners: []database.ProvisionerType{
|
|
database.ProvisionerTypeEcho, database.ProvisionerTypeTerraform,
|
|
},
|
|
Tags: provisionersdk.MutateTags(uuid.Nil, nil),
|
|
LastSeenAt: sql.NullTime{Time: dbtime.Now(), Valid: true},
|
|
Version: buildinfo.Version(),
|
|
APIVersion: proto.CurrentVersion.String(),
|
|
})
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("failed to create in-memory provisioner daemon: %w", err)
|
|
}
|
|
|
|
mux := drpcmux.New()
|
|
api.Logger.Info(dialCtx, "starting in-memory provisioner daemon", slog.F("name", name))
|
|
logger := api.Logger.Named(fmt.Sprintf("inmem-provisionerd-%s", name))
|
|
srv, err := provisionerdserver.NewServer(
|
|
api.ctx, // use the same ctx as the API
|
|
api.AccessURL,
|
|
daemon.ID,
|
|
defaultOrg.ID,
|
|
logger,
|
|
daemon.Provisioners,
|
|
provisionerdserver.Tags(daemon.Tags),
|
|
api.Database,
|
|
api.Pubsub,
|
|
api.Acquirer,
|
|
api.Telemetry,
|
|
tracer,
|
|
&api.QuotaCommitter,
|
|
&api.Auditor,
|
|
api.TemplateScheduleStore,
|
|
api.UserQuietHoursScheduleStore,
|
|
api.DeploymentValues,
|
|
provisionerdserver.Options{
|
|
OIDCConfig: api.OIDCConfig,
|
|
ExternalAuthConfigs: api.ExternalAuthConfigs,
|
|
},
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
err = proto.DRPCRegisterProvisionerDaemon(mux, srv)
|
|
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
|
|
}
|
|
logger.Debug(dialCtx, "drpc server error", slog.Error(err))
|
|
},
|
|
},
|
|
)
|
|
// in-mem pipes aren't technically "websockets" but they have the same properties as far as the
|
|
// API is concerned: they are long-lived connections that we need to close before completing
|
|
// shutdown of the API.
|
|
api.WebsocketWaitMutex.Lock()
|
|
api.WebsocketWaitGroup.Add(1)
|
|
api.WebsocketWaitMutex.Unlock()
|
|
go func() {
|
|
defer api.WebsocketWaitGroup.Done()
|
|
// here we pass the background context, since we want the server to keep serving until the
|
|
// client hangs up. If we, say, pass the API context, then when it is canceled, we could
|
|
// drop a job that we locked in the database but never passed to the provisionerd. The
|
|
// provisionerd is local, in-mem, so there isn't a danger of losing contact with it and
|
|
// having a dead connection we don't know the status of.
|
|
err := server.Serve(context.Background(), serverSession)
|
|
logger.Info(dialCtx, "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
|
|
}
|
|
|
|
func (api *API) DERPMap() *tailcfg.DERPMap {
|
|
fn := api.DERPMapper.Load()
|
|
if fn != nil {
|
|
return (*fn)(api.Options.BaseDERPMap)
|
|
}
|
|
|
|
return api.Options.BaseDERPMap
|
|
}
|
|
|
|
// nolint:revive
|
|
func ReadExperiments(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
|
|
}
|