mirror of
https://github.com/coder/coder.git
synced 2025-07-23 21:32:07 +00:00
chore: add workspace proxies to the backend (#7032)
Co-authored-by: Dean Sheather <dean@deansheather.com>
This commit is contained in:
250
enterprise/wsproxy/wsproxy.go
Normal file
250
enterprise/wsproxy/wsproxy.go
Normal file
@ -0,0 +1,250 @@
|
||||
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 <nil>", 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))
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user