Path: blob/main/components/image-builder-bob/pkg/proxy/proxy.go
2506 views
// Copyright (c) 2021 Gitpod GmbH. All rights reserved.1// Licensed under the GNU Affero General Public License (AGPL).2// See License.AGPL.txt in the project root for license information.34package proxy56import (7"context"8"fmt"9"io"10"net/http"11"net/http/httputil"12"net/url"13"strings"14"sync"1516"github.com/containerd/containerd/remotes/docker"17"github.com/gitpod-io/gitpod/common-go/log"18"github.com/hashicorp/go-retryablehttp"19)2021const authKey = "authKey"2223func NewProxy(host *url.URL, aliases map[string]Repo, mirrorAuth func() docker.Authorizer) (*Proxy, error) {24if host.Host == "" || host.Scheme == "" {25return nil, fmt.Errorf("host Host or Scheme are missing")26}27for k, v := range aliases {28// We need to translate the default hosts for the Docker registry.29// If we don't do this, pulling from docker.io will fail.30v.Host, _ = docker.DefaultHost(v.Host)31aliases[k] = v32}33return &Proxy{34Host: *host,35Aliases: aliases,36proxies: make(map[string]*httputil.ReverseProxy),37mirrorAuth: mirrorAuth,38}, nil39}4041type Proxy struct {42Host url.URL43Aliases map[string]Repo4445mu sync.Mutex46proxies map[string]*httputil.ReverseProxy47mirrorAuth func() docker.Authorizer48}4950type Repo struct {51Host string52Repo string53Tag string54Auth func() docker.Authorizer55}5657func rewriteDockerAPIURL(u *url.URL, fromRepo, toRepo, host, tag string) {58var (59from = "/v2/" + strings.Trim(fromRepo, "/") + "/"60to = "/v2/" + strings.Trim(toRepo, "/") + "/"61)62u.Path = to + strings.TrimPrefix(strings.TrimPrefix(u.Path, from), "/")6364// we reset the escaped encoding hint, because EscapedPath will produce a valid encoding.65u.RawPath = ""6667if tag != "" {68// We're forcing the image tag which only affects manifests. No matter what the user69// requested we look at, we'll force the tag to the one we're given.70segs := strings.Split(u.Path, "/")71if len(segs) >= 2 && segs[len(segs)-2] == "manifests" {72// We're on the manifest found, hence the last segment must be the reference.73// Even if the reference is a digest, we'll just force it to the tag.74// This might break some consumers, but we want to use the tag forcing as a means75// of excerting control, hence rather break folks than allow unauthorized access.76segs[len(segs)-1] = tag77u.Path = strings.Join(segs, "/")78}79}8081u.Host = host82}8384// rewriteNonDockerAPIURL is used when a url has to be rewritten but the url85// contains a non docker api path86func rewriteNonDockerAPIURL(u *url.URL, fromPrefix, toPrefix, host string) {87var (88from = "/" + strings.Trim(fromPrefix, "/") + "/"89to = "/" + strings.Trim(toPrefix, "/") + "/"90)91if fromPrefix == "" {92from = "/"93}94if toPrefix == "" {95to = "/"96}97u.Path = to + strings.TrimPrefix(strings.TrimPrefix(u.Path, from), "/")9899// we reset the escaped encoding hint, because EscapedPath will produce a valid encoding.100u.RawPath = ""101102u.Host = host103}104105// ServeHTTP serves the proxy106func (proxy *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {107ctx := r.Context()108109var (110repo *Repo111alias string112)113114// bypass for crane check115if r.URL.Path == "/v2/" {116w.WriteHeader(http.StatusOK)117_, _ = w.Write([]byte("{}"))118return119}120121for k, v := range proxy.Aliases {122// Docker api request123if strings.HasPrefix(r.URL.Path, "/v2/"+k+"/") {124repo = &v125alias = k126rewriteDockerAPIURL(r.URL, alias, repo.Repo, repo.Host, repo.Tag)127break128}129// Non-Docker api request130if strings.HasPrefix(r.URL.Path, "/"+k+"/") {131// We will use the same repo/alias and its credentials but we will set target132// repo as empty133repo = &v134alias = k135rewriteNonDockerAPIURL(r.URL, alias, "", repo.Host)136break137}138}139140// get mirror host141if host := r.URL.Query().Get("ns"); host != "" && (r.Method == http.MethodGet || r.Method == http.MethodHead) {142host, _ = docker.DefaultHost(host)143144r.URL.Host = host145r.Host = host146147auth := proxy.mirrorAuth()148r = r.WithContext(context.WithValue(ctx, authKey, auth))149150r.RequestURI = ""151proxy.mirror(host).ServeHTTP(w, r)152return153}154155if repo == nil {156http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)157return158}159160r.Host = r.URL.Host161162auth := repo.Auth()163r = r.WithContext(context.WithValue(ctx, authKey, auth))164165err := auth.Authorize(ctx, r)166if err != nil {167log.WithError(err).Error("cannot authorize request")168http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)169return170}171172log.WithField("req", r.URL.Path).Info("serving request")173174r.RequestURI = ""175proxy.reverse(alias).ServeHTTP(w, r)176}177178// reverse produces an authentication-adding reverse proxy for a given repo alias179func (proxy *Proxy) reverse(alias string) *httputil.ReverseProxy {180proxy.mu.Lock()181defer proxy.mu.Unlock()182183if rp, ok := proxy.proxies[alias]; ok {184return rp185}186187repo, ok := proxy.Aliases[alias]188if !ok {189// we don't have an alias, hence don't know what to do other than try and proxy.190// At this poing things will probably fail.191return nil192}193rp := httputil.NewSingleHostReverseProxy(&url.URL{Scheme: "https", Host: repo.Host})194195client := retryablehttp.NewClient()196client.RetryMax = 3197client.CheckRetry = func(ctx context.Context, resp *http.Response, err error) (bool, error) {198if err != nil {199log.WithError(err).Warn("saw error during CheckRetry")200return false, err201}202auth, ok := ctx.Value(authKey).(docker.Authorizer)203if !ok || auth == nil {204return false, nil205}206if resp.StatusCode == http.StatusUnauthorized {207// the docker authorizer only refreshes OAuth tokens after two208// successive 401 errors for the same URL. Rather than issue the same209// request multiple times to tickle the token-refreshing logic, just210// provide the same response twice to trick it into refreshing the211// cached OAuth token. Call AddResponses() twice, first to invalidate212// the existing token (with two responses), second to fetch a new one213// (with one response).214// TODO: fix after one of these two PRs are merged and available:215// https://github.com/containerd/containerd/pull/8735216// https://github.com/containerd/containerd/pull/8388217err := auth.AddResponses(ctx, []*http.Response{resp, resp})218if err != nil {219log.WithError(err).WithField("URL", resp.Request.URL.String()).Warn("cannot add responses although response was Unauthorized")220return false, nil221}222223err = auth.AddResponses(ctx, []*http.Response{resp})224if err != nil {225log.WithError(err).WithField("URL", resp.Request.URL.String()).Warn("cannot add responses although response was Unauthorized")226return false, nil227}228229return true, nil230}231if resp.StatusCode == http.StatusBadRequest {232bodyBytes, err := io.ReadAll(resp.Body)233if err != nil {234log.WithError(err).WithField("URL", resp.Request.URL.String()).Warn("failed to read response body")235}236237log.WithField("URL", resp.Request.URL.String()).WithField("Body", string(bodyBytes)).Warn("bad request")238return true, nil239}240241return false, nil242}243client.RequestLogHook = func(l retryablehttp.Logger, r *http.Request, i int) {244// Total hack: we need a place to modify the request before retrying, and this log245// hook seems to be the only place. We need to modify the request, because246// maybe we just added the host authorizer in the previous CheckRetry call.247//248// The ReverseProxy sets the X-Forwarded-For header with the host machine249// address. If on a cluster with IPV6 enabled, this will be "::1" (IPV6 equivalent250// of "127.0.0.1"). This can have the knock-on effect of receiving an IPV6251// URL, e.g. auth.ipv6.docker.com instead of auth.docker.com which may not252// exist. By forcing the value to be "127.0.0.1", we ensure consistency253// across clusters.254//255// @link https://golang.org/src/net/http/httputil/reverseproxy.go256r.Header.Set("X-Forwarded-For", "127.0.0.1")257258auth, ok := r.Context().Value(authKey).(docker.Authorizer)259if !ok || auth == nil {260return261}262_ = auth.Authorize(r.Context(), r)263}264client.ResponseLogHook = func(l retryablehttp.Logger, r *http.Response) {}265266rp.Transport = &retryablehttp.RoundTripper{267Client: client,268}269rp.ModifyResponse = func(r *http.Response) error {270// Some registries return a Location header which we must rewrite to still push271// through this proxy.272// We support only relative URLs and not absolute URLs.273if loc := r.Header.Get("Location"); loc != "" {274lurl, err := url.Parse(loc)275if err != nil {276return err277}278279if strings.HasPrefix(loc, "/v2/") {280rewriteDockerAPIURL(lurl, repo.Repo, alias, proxy.Host.Host, "")281} else {282// since this is a non docker api location we283// do not need to process the path.284// All docker api URLs always start with /v2/. See spec285// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#endpoints286rewriteNonDockerAPIURL(lurl, "", alias, repo.Host)287}288289lurl.Host = proxy.Host.Host290// force scheme to http assuming this proxy never runs as https291lurl.Scheme = proxy.Host.Scheme292r.Header.Set("Location", lurl.String())293}294295if r.StatusCode == http.StatusBadGateway {296// BadGateway makes containerd retry - we don't want that because we retry the upstream297// requests internally.298r.StatusCode = http.StatusInternalServerError299r.Status = http.StatusText(http.StatusInternalServerError)300}301302return nil303}304proxy.proxies[alias] = rp305return rp306}307308// mirror produces an authentication-adding reverse proxy for given host309func (proxy *Proxy) mirror(host string) *httputil.ReverseProxy {310proxy.mu.Lock()311defer proxy.mu.Unlock()312313if rp, ok := proxy.proxies[host]; ok {314return rp315}316317rp := httputil.NewSingleHostReverseProxy(&url.URL{Scheme: "https", Host: host})318319client := retryablehttp.NewClient()320client.RetryMax = 3321client.CheckRetry = func(ctx context.Context, resp *http.Response, err error) (bool, error) {322if err != nil {323log.WithError(err).Warn("saw error during CheckRetry")324return false, err325}326auth, ok := ctx.Value(authKey).(docker.Authorizer)327if !ok || auth == nil {328return false, nil329}330if resp.StatusCode == http.StatusUnauthorized {331// the docker authorizer only refreshes OAuth tokens after two332// successive 401 errors for the same URL. Rather than issue the same333// request multiple times to tickle the token-refreshing logic, just334// provide the same response twice to trick it into refreshing the335// cached OAuth token. Call AddResponses() twice, first to invalidate336// the existing token (with two responses), second to fetch a new one337// (with one response).338// TODO: fix after one of these two PRs are merged and available:339// https://github.com/containerd/containerd/pull/8735340// https://github.com/containerd/containerd/pull/8388341err := auth.AddResponses(ctx, []*http.Response{resp, resp})342if err != nil {343log.WithError(err).WithField("URL", resp.Request.URL.String()).Warn("cannot add responses although response was Unauthorized")344return false, nil345}346347err = auth.AddResponses(ctx, []*http.Response{resp})348if err != nil {349log.WithError(err).WithField("URL", resp.Request.URL.String()).Warn("cannot add responses although response was Unauthorized")350return false, nil351}352return true, nil353}354if resp.StatusCode == http.StatusBadRequest {355log.WithField("URL", resp.Request.URL.String()).Warn("bad request")356return true, nil357}358359return false, nil360}361client.RequestLogHook = func(l retryablehttp.Logger, r *http.Request, i int) {362// Total hack: we need a place to modify the request before retrying, and this log363// hook seems to be the only place. We need to modify the request, because364// maybe we just added the host authorizer in the previous CheckRetry call.365//366// The ReverseProxy sets the X-Forwarded-For header with the host machine367// address. If on a cluster with IPV6 enabled, this will be "::1" (IPV6 equivalent368// of "127.0.0.1"). This can have the knock-on effect of receiving an IPV6369// URL, e.g. auth.ipv6.docker.com instead of auth.docker.com which may not370// exist. By forcing the value to be "127.0.0.1", we ensure consistency371// across clusters.372//373// @link https://golang.org/src/net/http/httputil/reverseproxy.go374r.Header.Set("X-Forwarded-For", "127.0.0.1")375376auth, ok := r.Context().Value(authKey).(docker.Authorizer)377if !ok || auth == nil {378return379}380_ = auth.Authorize(r.Context(), r)381}382client.ResponseLogHook = func(l retryablehttp.Logger, r *http.Response) {}383384rp.Transport = &retryablehttp.RoundTripper{385Client: client,386}387rp.ModifyResponse = func(r *http.Response) error {388if r.StatusCode == http.StatusBadGateway {389// BadGateway makes containerd retry - we don't want that because we retry the upstream390// requests internally.391r.StatusCode = http.StatusInternalServerError392r.Status = http.StatusText(http.StatusInternalServerError)393}394395return nil396}397proxy.proxies[host] = rp398return rp399}400401402