package proxy
import (
"crypto/tls"
"fmt"
"net"
"net/http"
"net/http/httputil"
"net/url"
"strings"
"syscall"
"time"
"github.com/sirupsen/logrus"
"golang.org/x/xerrors"
"github.com/gitpod-io/gitpod/common-go/log"
"github.com/gitpod-io/gitpod/ws-proxy/pkg/common"
)
type proxyPassConfig struct {
TargetResolver targetResolver
ResponseHandler []responseHandler
ErrorHandler errorHandler
Transport http.RoundTripper
UseTargetHost bool
}
func (ppc *proxyPassConfig) appendResponseHandler(handler responseHandler) {
ppc.ResponseHandler = append(ppc.ResponseHandler, handler)
}
type proxyPassOpt func(h *proxyPassConfig)
type createHttpTransportOpt func(h *http.Transport)
type errorHandler func(http.ResponseWriter, *http.Request, error)
type targetResolver func(*Config, common.WorkspaceInfoProvider, *http.Request) (*url.URL, string, error)
type responseHandler func(*http.Response, *http.Request) error
func singleJoiningSlash(a, b string) string {
aslash := strings.HasSuffix(a, "/")
bslash := strings.HasPrefix(b, "/")
switch {
case aslash && bslash:
return a + b[1:]
case !aslash && !bslash:
return a + "/" + b
}
return a + b
}
func joinURLPath(a, b *url.URL) (path, rawpath string) {
if a.RawPath == "" && b.RawPath == "" {
return singleJoiningSlash(a.Path, b.Path), ""
}
apath := a.EscapedPath()
bpath := b.EscapedPath()
aslash := strings.HasSuffix(apath, "/")
bslash := strings.HasPrefix(bpath, "/")
switch {
case aslash && bslash:
return a.Path + b.Path[1:], apath + bpath[1:]
case !aslash && !bslash:
return a.Path + "/" + b.Path, apath + "/" + bpath
}
return a.Path + b.Path, apath + bpath
}
func NewSingleHostReverseProxy(target *url.URL, useTargetHost bool) *httputil.ReverseProxy {
targetQuery := target.RawQuery
director := func(req *http.Request) {
req.URL.Scheme = target.Scheme
req.URL.Host = target.Host
if useTargetHost {
req.Host = target.Host
}
req.URL.Path, req.URL.RawPath = joinURLPath(target, req.URL)
if targetQuery == "" || req.URL.RawQuery == "" {
req.URL.RawQuery = targetQuery + req.URL.RawQuery
} else {
req.URL.RawQuery = targetQuery + "&" + req.URL.RawQuery
}
if _, ok := req.Header["User-Agent"]; !ok {
req.Header.Set("User-Agent", "")
}
}
return &httputil.ReverseProxy{Director: director}
}
func proxyPass(config *RouteHandlerConfig, infoProvider common.WorkspaceInfoProvider, resolver targetResolver, opts ...proxyPassOpt) http.HandlerFunc {
h := proxyPassConfig{
Transport: config.DefaultTransport,
}
for _, o := range opts {
o(&h)
}
h.TargetResolver = resolver
if h.ErrorHandler != nil {
oeh := h.ErrorHandler
h.ErrorHandler = func(w http.ResponseWriter, req *http.Request, connectErr error) {
log.Debugf("could not connect to backend %s: %s", req.URL.String(), connectErrorToCause(connectErr))
oeh(w, req, connectErr)
}
}
return func(w http.ResponseWriter, req *http.Request) {
targetURL, targetResource, err := h.TargetResolver(config.Config, infoProvider, req)
if err != nil {
if h.ErrorHandler != nil {
h.ErrorHandler(w, req, err)
} else {
log.WithError(err).Errorf("Unable to resolve targetURL: %s", req.URL.String())
}
return
}
req = withResourceMetricsLabel(req, targetResource)
req = withHttpVersionMetricsLabel(req)
originalURL := *req.URL
proxy := NewSingleHostReverseProxy(targetURL, h.UseTargetHost)
proxy.Transport = h.Transport
proxy.ModifyResponse = func(resp *http.Response) error {
url := resp.Request.URL
if url == nil {
return xerrors.Errorf("response's request without URL")
}
if log.Log.Level <= logrus.DebugLevel && resp.StatusCode >= http.StatusBadRequest {
dmp, _ := httputil.DumpRequest(resp.Request, false)
log.WithField("url", url.String()).WithField("req", dmp).WithField("status", resp.Status).Debug("proxied request failed")
}
for _, handler := range h.ResponseHandler {
err := handler(resp, req)
if err != nil {
return err
}
}
return nil
}
proxy.ErrorHandler = func(rw http.ResponseWriter, req *http.Request, err error) {
if h.ErrorHandler != nil {
req.URL = &originalURL
h.ErrorHandler(w, req, err)
return
}
if !strings.HasPrefix(originalURL.Path, "/_supervisor/") {
log.WithField("url", originalURL.String()).WithError(err).Debug("proxied request failed")
}
rw.WriteHeader(http.StatusBadGateway)
}
getLog(req.Context()).WithField("targetURL", targetURL.String()).Debug("proxy-passing request")
proxy.ServeHTTP(w, req)
}
}
func connectErrorToCause(err error) string {
if err == nil {
return ""
}
if netError, ok := err.(net.Error); ok && netError.Timeout() {
return "Connect timeout"
}
switch t := err.(type) {
case *net.OpError:
if t.Op == "dial" {
return fmt.Sprintf("Unknown host: %s", err.Error())
} else if t.Op == "read" {
return fmt.Sprintf("Connection refused: %s", err.Error())
}
case syscall.Errno:
if t == syscall.ECONNREFUSED {
return "Connection refused"
}
}
return err.Error()
}
func withHTTPErrorHandler(h http.Handler) proxyPassOpt {
return func(cfg *proxyPassConfig) {
cfg.ErrorHandler = func(w http.ResponseWriter, req *http.Request, err error) {
h.ServeHTTP(w, req)
}
}
}
func withErrorHandler(h errorHandler) proxyPassOpt {
return func(cfg *proxyPassConfig) {
cfg.ErrorHandler = h
}
}
func withSkipTLSVerify() createHttpTransportOpt {
return func(tr *http.Transport) {
tr.TLSClientConfig = &tls.Config{
InsecureSkipVerify: true,
}
}
}
func createDefaultTransport(config *TransportConfig, opts ...createHttpTransportOpt) http.RoundTripper {
transport := &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: time.Duration(config.ConnectTimeout),
KeepAlive: 30 * time.Second,
DualStack: true,
}).DialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: config.MaxIdleConns,
MaxIdleConnsPerHost: config.MaxIdleConnsPerHost,
IdleConnTimeout: time.Duration(config.IdleConnTimeout),
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
for _, o := range opts {
o(transport)
}
return instrumentClientMetrics(transport)
}
func withLongTermCaching() proxyPassOpt {
return func(cfg *proxyPassConfig) {
cfg.appendResponseHandler(func(resp *http.Response, req *http.Request) error {
if resp.StatusCode < http.StatusBadRequest {
resp.Header.Set("Cache-Control", "public, max-age=31536000")
}
return nil
})
}
}
func withXFrameOptionsFilter() proxyPassOpt {
return func(cfg *proxyPassConfig) {
cfg.appendResponseHandler(func(resp *http.Response, req *http.Request) error {
resp.Header.Del("X-Frame-Options")
return nil
})
}
}
func withUseTargetHost() proxyPassOpt {
return func(cfg *proxyPassConfig) {
cfg.UseTargetHost = true
}
}