package wsproxy import ( "context" "net/http" "net/url" "reflect" "regexp" "strings" "time" "github.com/coder/coder/coderd/httpapi" "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" "go.opentelemetry.io/otel/trace" "golang.org/x/xerrors" "cdr.dev/slog" "github.com/coder/coder/buildinfo" "github.com/coder/coder/coderd/httpmw" "github.com/coder/coder/coderd/tracing" "github.com/coder/coder/coderd/workspaceapps" "github.com/coder/coder/coderd/wsconncache" "github.com/coder/coder/codersdk" "github.com/coder/coder/enterprise/wsproxy/wsproxysdk" ) type Options struct { Logger slog.Logger // DashboardURL is the URL of the primary coderd instance. DashboardURL *url.URL // AccessURL is the URL of the WorkspaceProxy. This is the url to communicate // with this server. AccessURL *url.URL // TODO: @emyrk We use these two fields in many places with this comment. // Maybe we should make some shared options struct? // 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 RealIPConfig *httpmw.RealIPConfig // TODO: @emyrk this key needs to be provided via a file or something? // Maybe we should curl it from the primary over some secure connection? AppSecurityKey workspaceapps.SecurityKey Tracing trace.TracerProvider PrometheusRegistry *prometheus.Registry APIRateLimit int SecureAuthCookie bool DisablePathApps bool ProxySessionToken string } func (o *Options) Validate() error { var errs optErrors errs.Required("Logger", o.Logger) errs.Required("DashboardURL", o.DashboardURL) errs.Required("AccessURL", o.AccessURL) errs.Required("RealIPConfig", o.RealIPConfig) errs.Required("PrometheusRegistry", o.PrometheusRegistry) errs.NotEmpty("ProxySessionToken", o.ProxySessionToken) errs.NotEmpty("AppSecurityKey", o.AppSecurityKey) if len(errs) > 0 { return errs } return nil } // Server is an external workspace proxy server. This server can communicate // directly with a workspace. It requires a primary coderd to establish a said // connection. type Server struct { Options *Options Handler chi.Router DashboardURL *url.URL AppServer *workspaceapps.Server // Logging/Metrics Logger slog.Logger TracerProvider trace.TracerProvider PrometheusRegistry *prometheus.Registry // SDKClient is a client to the primary coderd instance authenticated with // the moon's token. SDKClient *wsproxysdk.Client // TODO: Missing: // - derpserver // Used for graceful shutdown. Required for the dialer. ctx context.Context cancel context.CancelFunc } func New(opts *Options) (*Server, error) { if opts.PrometheusRegistry == nil { opts.PrometheusRegistry = prometheus.NewRegistry() } if err := opts.Validate(); err != nil { return nil, err } // TODO: implement some ping and registration logic client := wsproxysdk.New(opts.DashboardURL) err := client.SetSessionToken(opts.ProxySessionToken) if err != nil { return nil, xerrors.Errorf("set client token: %w", err) } r := chi.NewRouter() ctx, cancel := context.WithCancel(context.Background()) s := &Server{ Options: opts, Handler: r, DashboardURL: opts.DashboardURL, Logger: opts.Logger.Named("workspace-proxy"), TracerProvider: opts.Tracing, PrometheusRegistry: opts.PrometheusRegistry, SDKClient: client, ctx: ctx, cancel: cancel, } s.AppServer = &workspaceapps.Server{ Logger: opts.Logger.Named("workspaceapps"), DashboardURL: opts.DashboardURL, AccessURL: opts.AccessURL, Hostname: opts.AppHostname, HostnameRegex: opts.AppHostnameRegex, RealIPConfig: opts.RealIPConfig, SignedTokenProvider: &TokenProvider{ DashboardURL: opts.DashboardURL, AccessURL: opts.AccessURL, AppHostname: opts.AppHostname, Client: client, SecurityKey: s.Options.AppSecurityKey, Logger: s.Logger.Named("proxy_token_provider"), }, WorkspaceConnCache: wsconncache.New(s.DialWorkspaceAgent, 0), AppSecurityKey: opts.AppSecurityKey, DisablePathApps: opts.DisablePathApps, SecureAuthCookie: opts.SecureAuthCookie, } // Routes apiRateLimiter := httpmw.RateLimit(opts.APIRateLimit, time.Minute) // Persistent middlewares to all routes r.Use( // TODO: @emyrk Should we standardize these in some other package? httpmw.Recover(s.Logger), tracing.StatusWriterMiddleware, tracing.Middleware(s.TracerProvider), httpmw.AttachRequestID, httpmw.ExtractRealIP(s.Options.RealIPConfig), httpmw.Logger(s.Logger), httpmw.Prometheus(s.PrometheusRegistry), // HandleSubdomain is a middleware that handles all requests to the // subdomain-based workspace apps. s.AppServer.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) }) }, // TODO: @emyrk we might not need this? But good to have if it does // not break anything. httpmw.CSRF(s.Options.SecureAuthCookie), ) // Attach workspace apps routes. r.Group(func(r chi.Router) { r.Use(apiRateLimiter) s.AppServer.Attach(r) }) r.Get("/buildinfo", s.buildInfo) r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("OK")) }) return s, nil } func (s *Server) Close() error { s.cancel() return s.AppServer.Close() } func (s *Server) DialWorkspaceAgent(id uuid.UUID) (*codersdk.WorkspaceAgentConn, error) { return s.SDKClient.DialWorkspaceAgent(s.ctx, id, nil) } func (s *Server) buildInfo(rw http.ResponseWriter, r *http.Request) { httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.BuildInfoResponse{ ExternalURL: buildinfo.ExternalURL(), Version: buildinfo.Version(), DashboardURL: s.DashboardURL.String(), }) } type optErrors []error func (e optErrors) Error() string { var b strings.Builder for _, err := range e { _, _ = b.WriteString(err.Error()) _, _ = b.WriteString("\n") } return b.String() } func (e *optErrors) Required(name string, v any) { if v == nil { *e = append(*e, xerrors.Errorf("%s is required, got ", name)) } } func (e *optErrors) NotEmpty(name string, v any) { if reflect.ValueOf(v).IsZero() { *e = append(*e, xerrors.Errorf("%s is required, got the zero value", name)) } }