coder/enterprise/cli/proxyserver.go

362 lines
12 KiB
Go

//go:build !slim
package cli
import (
"context"
"fmt"
"io"
"log"
"net"
"net/http"
"net/http/pprof"
"os/signal"
"regexp"
rpprof "runtime/pprof"
"time"
"github.com/coreos/go-systemd/daemon"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/collectors"
"github.com/prometheus/client_golang/prometheus/promhttp"
"golang.org/x/xerrors"
"cdr.dev/slog"
"github.com/coder/coder/cli"
"github.com/coder/coder/cli/clibase"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/httpmw"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/enterprise/wsproxy"
)
type closers []func()
func (c closers) Close() {
for _, closeF := range c {
closeF()
}
}
func (c *closers) Add(f func()) {
*c = append(*c, f)
}
func (*RootCmd) proxyServer() *clibase.Cmd {
var (
cfg = new(codersdk.DeploymentValues)
// Filter options for only relevant ones.
opts = cfg.Options().Filter(codersdk.IsWorkspaceProxies)
externalProxyOptionGroup = clibase.Group{
Name: "External Workspace Proxy",
YAML: "externalWorkspaceProxy",
}
proxySessionToken clibase.String
primaryAccessURL clibase.URL
)
opts.Add(
// Options only for external workspace proxies
clibase.Option{
Name: "Proxy Session Token",
Description: "Authentication token for the workspace proxy to communicate with coderd.",
Flag: "proxy-session-token",
Env: "CODER_PROXY_SESSION_TOKEN",
YAML: "proxySessionToken",
Default: "",
Value: &proxySessionToken,
Group: &externalProxyOptionGroup,
Hidden: false,
},
clibase.Option{
Name: "Coderd (Primary) Access URL",
Description: "URL to communicate with coderd. This should match the access URL of the Coder deployment.",
Flag: "primary-access-url",
Env: "CODER_PRIMARY_ACCESS_URL",
YAML: "primaryAccessURL",
Default: "",
Value: &primaryAccessURL,
Group: &externalProxyOptionGroup,
Hidden: false,
},
)
cmd := &clibase.Cmd{
Use: "server",
Short: "Start a workspace proxy server",
Options: opts,
Middleware: clibase.Chain(
cli.WriteConfigMW(cfg),
cli.PrintDeprecatedOptions(),
clibase.RequireNArgs(0),
),
Handler: func(inv *clibase.Invocation) error {
if !(primaryAccessURL.Scheme == "http" || primaryAccessURL.Scheme == "https") {
return xerrors.Errorf("primary access URL must be http or https: url=%s", primaryAccessURL.String())
}
var closers closers
// Main command context for managing cancellation of running
// services.
ctx, topCancel := context.WithCancel(inv.Context())
defer topCancel()
closers.Add(topCancel)
go cli.DumpHandler(ctx)
cli.PrintLogo(inv)
logger, logCloser, err := cli.BuildLogger(inv, cfg)
if err != nil {
return xerrors.Errorf("make logger: %w", err)
}
defer logCloser()
closers.Add(logCloser)
logger.Debug(ctx, "started debug logging")
logger.Sync()
// Register signals early on so that graceful shutdown can't
// be interrupted by additional signals. Note that we avoid
// shadowing cancel() (from above) here because notifyStop()
// restores default behavior for the signals. This protects
// the shutdown sequence from abruptly terminating things
// like: database migrations, provisioner work, workspace
// cleanup in dev-mode, etc.
//
// To get out of a graceful shutdown, the user can send
// SIGQUIT with ctrl+\ or SIGKILL with `kill -9`.
notifyCtx, notifyStop := signal.NotifyContext(ctx, cli.InterruptSignals...)
defer notifyStop()
// Clean up idle connections at the end, e.g.
// embedded-postgres can leave an idle connection
// which is caught by goleaks.
defer http.DefaultClient.CloseIdleConnections()
closers.Add(http.DefaultClient.CloseIdleConnections)
tracer, _, closeTracing := cli.ConfigureTraceProvider(ctx, logger, inv, cfg)
defer func() {
logger.Debug(ctx, "closing tracing")
traceCloseErr := shutdownWithTimeout(closeTracing, 5*time.Second)
logger.Debug(ctx, "tracing closed", slog.Error(traceCloseErr))
}()
httpServers, err := cli.ConfigureHTTPServers(inv, cfg)
if err != nil {
return xerrors.Errorf("configure http(s): %w", err)
}
defer httpServers.Close()
closers.Add(httpServers.Close)
// If no access url given, use the local address.
if cfg.AccessURL.String() == "" {
// Prefer TLS
if httpServers.TLSUrl != nil {
cfg.AccessURL = clibase.URL(*httpServers.TLSUrl)
} else if httpServers.HTTPUrl != nil {
cfg.AccessURL = clibase.URL(*httpServers.HTTPUrl)
}
}
// TODO: @emyrk I find this strange that we add this to the context
// at the root here.
ctx, httpClient, err := cli.ConfigureHTTPClient(
ctx,
cfg.TLS.ClientCertFile.String(),
cfg.TLS.ClientKeyFile.String(),
cfg.TLS.ClientCAFile.String(),
)
if err != nil {
return xerrors.Errorf("configure http client: %w", err)
}
defer httpClient.CloseIdleConnections()
closers.Add(httpClient.CloseIdleConnections)
// Warn the user if the access URL appears to be a loopback address.
isLocal, err := cli.IsLocalURL(ctx, cfg.AccessURL.Value())
if isLocal || err != nil {
reason := "could not be resolved"
if isLocal {
reason = "isn't externally reachable"
}
cliui.Warnf(
inv.Stderr,
"The access URL %s %s, this may cause unexpected problems when creating workspaces. Generate a unique *.try.coder.app URL by not specifying an access URL.\n",
cliui.Styles.Field.Render(cfg.AccessURL.String()), reason,
)
}
// A newline is added before for visibility in terminal output.
cliui.Infof(inv.Stdout, "\nView the Web UI: %s", cfg.AccessURL.String())
var appHostnameRegex *regexp.Regexp
appHostname := cfg.WildcardAccessURL.String()
if appHostname != "" {
appHostnameRegex, err = httpapi.CompileHostnamePattern(appHostname)
if err != nil {
return xerrors.Errorf("parse wildcard access URL %q: %w", appHostname, err)
}
}
realIPConfig, err := httpmw.ParseRealIPConfig(cfg.ProxyTrustedHeaders, cfg.ProxyTrustedOrigins)
if err != nil {
return xerrors.Errorf("parse real ip config: %w", err)
}
if cfg.Pprof.Enable {
// This prevents the pprof import from being accidentally deleted.
// pprof has an init function that attaches itself to the default handler.
// By passing a nil handler to 'serverHandler', it will automatically use
// the default, which has pprof attached.
_ = pprof.Handler
//nolint:revive
closeFunc := cli.ServeHandler(ctx, logger, nil, cfg.Pprof.Address.String(), "pprof")
defer closeFunc()
closers.Add(closeFunc)
}
prometheusRegistry := prometheus.NewRegistry()
if cfg.Prometheus.Enable {
prometheusRegistry.MustRegister(collectors.NewGoCollector())
prometheusRegistry.MustRegister(collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}))
//nolint:revive
closeFunc := cli.ServeHandler(ctx, logger, promhttp.InstrumentMetricHandler(
prometheusRegistry, promhttp.HandlerFor(prometheusRegistry, promhttp.HandlerOpts{}),
), cfg.Prometheus.Address.String(), "prometheus")
defer closeFunc()
closers.Add(closeFunc)
}
proxy, err := wsproxy.New(ctx, &wsproxy.Options{
Logger: logger,
HTTPClient: httpClient,
DashboardURL: primaryAccessURL.Value(),
AccessURL: cfg.AccessURL.Value(),
AppHostname: appHostname,
AppHostnameRegex: appHostnameRegex,
RealIPConfig: realIPConfig,
Tracing: tracer,
PrometheusRegistry: prometheusRegistry,
APIRateLimit: int(cfg.RateLimit.API.Value()),
SecureAuthCookie: cfg.SecureAuthCookie.Value(),
DisablePathApps: cfg.DisablePathApps.Value(),
ProxySessionToken: proxySessionToken.Value(),
AllowAllCors: cfg.Dangerous.AllowAllCors.Value(),
})
if err != nil {
return xerrors.Errorf("create workspace proxy: %w", err)
}
closers.Add(func() { _ = proxy.Close() })
shutdownConnsCtx, shutdownConns := context.WithCancel(ctx)
defer shutdownConns()
closers.Add(shutdownConns)
// ReadHeaderTimeout is purposefully not enabled. It caused some
// issues with websockets over the dev tunnel.
// See: https://github.com/coder/coder/pull/3730
//nolint:gosec
httpServer := &http.Server{
// These errors are typically noise like "TLS: EOF". Vault does
// similar:
// https://github.com/hashicorp/vault/blob/e2490059d0711635e529a4efcbaa1b26998d6e1c/command/server.go#L2714
ErrorLog: log.New(io.Discard, "", 0),
Handler: proxy.Handler,
BaseContext: func(_ net.Listener) context.Context {
return shutdownConnsCtx
},
}
defer func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = httpServer.Shutdown(ctx)
}()
// TODO: So this obviously is not going to work well.
errCh := make(chan error, 1)
go rpprof.Do(ctx, rpprof.Labels("service", "workspace-proxy"), func(ctx context.Context) {
errCh <- httpServers.Serve(httpServer)
})
cliui.Infof(inv.Stdout, "\n==> Logs will stream in below (press ctrl+c to gracefully exit):")
// Updates the systemd status from activating to activated.
_, err = daemon.SdNotify(false, daemon.SdNotifyReady)
if err != nil {
return xerrors.Errorf("notify systemd: %w", err)
}
// Currently there is no way to ask the server to shut
// itself down, so any exit signal will result in a non-zero
// exit of the server.
var exitErr error
select {
case exitErr = <-errCh:
case <-notifyCtx.Done():
exitErr = notifyCtx.Err()
_, _ = fmt.Fprintln(inv.Stdout, cliui.Styles.Bold.Render(
"Interrupt caught, gracefully exiting. Use ctrl+\\ to force quit",
))
}
if exitErr != nil && !xerrors.Is(exitErr, context.Canceled) {
cliui.Errorf(inv.Stderr, "Unexpected error, shutting down server: %s\n", exitErr)
}
// Begin clean shut down stage, we try to shut down services
// gracefully in an order that gives the best experience.
// This procedure should not differ greatly from the order
// of `defer`s in this function, but allows us to inform
// the user about what's going on and handle errors more
// explicitly.
_, err = daemon.SdNotify(false, daemon.SdNotifyStopping)
if err != nil {
cliui.Errorf(inv.Stderr, "Notify systemd failed: %s", err)
}
// Stop accepting new connections without interrupting
// in-flight requests, give in-flight requests 5 seconds to
// complete.
cliui.Info(inv.Stdout, "Shutting down API server..."+"\n")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
err = httpServer.Shutdown(shutdownCtx)
if err != nil {
cliui.Errorf(inv.Stderr, "API server shutdown took longer than 3s: %s\n", err)
} else {
cliui.Info(inv.Stdout, "Gracefully shut down API server\n")
}
// Cancel any remaining in-flight requests.
shutdownConns()
// Trigger context cancellation for any remaining services.
closers.Close()
switch {
case xerrors.Is(exitErr, context.DeadlineExceeded):
cliui.Warnf(inv.Stderr, "Graceful shutdown timed out")
// Errors here cause a significant number of benign CI failures.
return nil
case xerrors.Is(exitErr, context.Canceled):
return nil
case exitErr != nil:
return xerrors.Errorf("graceful shutdown: %w", exitErr)
default:
return nil
}
},
}
return cmd
}
func shutdownWithTimeout(shutdown func(context.Context) error, timeout time.Duration) error {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return shutdown(ctx)
}