From 0b29c9c37116e402f61a5d6766bbb3fc0b451ec9 Mon Sep 17 00:00:00 2001 From: Derek McGowan Date: Tue, 28 May 2019 10:29:41 -0700 Subject: [PATCH] Update resolver to handle endpoint configuration Adds support for registry mirrors Adds support for multiple pull endpoints Adds capabilities to limit trust in public mirrors Fixes user agent header missing Signed-off-by: Derek McGowan --- remotes/docker/authorizer.go | 82 +++-- remotes/docker/fetcher.go | 131 ++++---- remotes/docker/fetcher_test.go | 47 ++- remotes/docker/pusher.go | 138 +++++---- remotes/docker/registry.go | 202 +++++++++++++ remotes/docker/registry_test.go | 76 +++++ remotes/docker/resolver.go | 512 +++++++++++++++++++------------- remotes/docker/resolver_test.go | 36 ++- 8 files changed, 861 insertions(+), 363 deletions(-) create mode 100644 remotes/docker/registry.go create mode 100644 remotes/docker/registry_test.go diff --git a/remotes/docker/authorizer.go b/remotes/docker/authorizer.go index bec9b7a0c..710bb574b 100644 --- a/remotes/docker/authorizer.go +++ b/remotes/docker/authorizer.go @@ -31,7 +31,6 @@ import ( "github.com/containerd/containerd/errdefs" "github.com/containerd/containerd/log" - "github.com/containerd/containerd/version" "github.com/pkg/errors" "github.com/sirupsen/logrus" "golang.org/x/net/context/ctxhttp" @@ -41,7 +40,7 @@ type dockerAuthorizer struct { credentials func(string) (string, string, error) client *http.Client - ua string + header http.Header mu sync.Mutex // indexed by host name @@ -50,15 +49,58 @@ type dockerAuthorizer struct { // NewAuthorizer creates a Docker authorizer using the provided function to // get credentials for the token server or basic auth. +// Deprecated: Use NewDockerAuthorizer func NewAuthorizer(client *http.Client, f func(string) (string, string, error)) Authorizer { - if client == nil { - client = http.DefaultClient + return NewDockerAuthorizer(WithAuthClient(client), WithAuthCreds(f)) +} + +type authorizerConfig struct { + credentials func(string) (string, string, error) + client *http.Client + header http.Header +} + +// AuthorizerOpt configures an authorizer +type AuthorizerOpt func(*authorizerConfig) + +// WithAuthClient provides the HTTP client for the authorizer +func WithAuthClient(client *http.Client) AuthorizerOpt { + return func(opt *authorizerConfig) { + opt.client = client + } +} + +// WithAuthCreds provides a credential function to the authorizer +func WithAuthCreds(creds func(string) (string, string, error)) AuthorizerOpt { + return func(opt *authorizerConfig) { + opt.credentials = creds + } +} + +// WithAuthHeader provides HTTP headers for authorization +func WithAuthHeader(hdr http.Header) AuthorizerOpt { + return func(opt *authorizerConfig) { + opt.header = hdr + } +} + +// NewDockerAuthorizer creates an authorizer using Docker's registry +// authentication spec. +// See https://docs.docker.com/registry/spec/auth/ +func NewDockerAuthorizer(opts ...AuthorizerOpt) Authorizer { + var ao authorizerConfig + for _, opt := range opts { + opt(&ao) + } + + if ao.client == nil { + ao.client = http.DefaultClient } return &dockerAuthorizer{ - credentials: f, - client: client, - ua: "containerd/" + version.Version, + credentials: ao.credentials, + client: ao.client, + header: ao.header, handlers: make(map[string]*authHandler), } } @@ -115,7 +157,7 @@ func (a *dockerAuthorizer) AddResponses(ctx context.Context, responses []*http.R return err } - a.handlers[host] = newAuthHandler(a.client, a.ua, c.scheme, common) + a.handlers[host] = newAuthHandler(a.client, a.header, c.scheme, common) return nil } else if c.scheme == basicAuth && a.credentials != nil { username, secret, err := a.credentials(host) @@ -129,7 +171,7 @@ func (a *dockerAuthorizer) AddResponses(ctx context.Context, responses []*http.R secret: secret, } - a.handlers[host] = newAuthHandler(a.client, a.ua, c.scheme, common) + a.handlers[host] = newAuthHandler(a.client, a.header, c.scheme, common) return nil } } @@ -179,7 +221,7 @@ type authResult struct { type authHandler struct { sync.Mutex - ua string + header http.Header client *http.Client @@ -194,13 +236,9 @@ type authHandler struct { scopedTokens map[string]*authResult } -func newAuthHandler(client *http.Client, ua string, scheme authenticationScheme, opts tokenOptions) *authHandler { - if client == nil { - client = http.DefaultClient - } - +func newAuthHandler(client *http.Client, hdr http.Header, scheme authenticationScheme, opts tokenOptions) *authHandler { return &authHandler{ - ua: ua, + header: hdr, client: client, scheme: scheme, common: opts, @@ -313,8 +351,10 @@ func (ah *authHandler) fetchTokenWithOAuth(ctx context.Context, to tokenOptions) return "", err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8") - if ah.ua != "" { - req.Header.Set("User-Agent", ah.ua) + if ah.header != nil { + for k, v := range ah.header { + req.Header[k] = append(req.Header[k], v...) + } } resp, err := ctxhttp.Do(ctx, ah.client, req) @@ -363,8 +403,10 @@ func (ah *authHandler) fetchToken(ctx context.Context, to tokenOptions) (string, return "", err } - if ah.ua != "" { - req.Header.Set("User-Agent", ah.ua) + if ah.header != nil { + for k, v := range ah.header { + req.Header[k] = append(req.Header[k], v...) + } } reqParams := req.URL.Query() diff --git a/remotes/docker/fetcher.go b/remotes/docker/fetcher.go index 6f06b0e50..ce3da5524 100644 --- a/remotes/docker/fetcher.go +++ b/remotes/docker/fetcher.go @@ -23,7 +23,7 @@ import ( "io" "io/ioutil" "net/http" - "path" + "net/url" "strings" "github.com/containerd/containerd/errdefs" @@ -32,7 +32,6 @@ import ( "github.com/docker/distribution/registry/api/errcode" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/pkg/errors" - "github.com/sirupsen/logrus" ) type dockerFetcher struct { @@ -40,26 +39,46 @@ type dockerFetcher struct { } func (r dockerFetcher) Fetch(ctx context.Context, desc ocispec.Descriptor) (io.ReadCloser, error) { - ctx = log.WithLogger(ctx, log.G(ctx).WithFields( - logrus.Fields{ - "base": r.base.String(), - "digest": desc.Digest, - }, - )) + ctx = log.WithLogger(ctx, log.G(ctx).WithField("digest", desc.Digest)) - urls, err := r.getV2URLPaths(ctx, desc) - if err != nil { - return nil, err + hosts := r.filterHosts(HostCapabilityPull) + if len(hosts) == 0 { + return nil, errors.Wrap(errdefs.ErrNotFound, "no pull hosts") } - ctx, err = contextWithRepositoryScope(ctx, r.refspec, false) + ctx, err := contextWithRepositoryScope(ctx, r.refspec, false) if err != nil { return nil, err } return newHTTPReadSeeker(desc.Size, func(offset int64) (io.ReadCloser, error) { - for _, u := range urls { - rc, err := r.open(ctx, u, desc.MediaType, offset) + // firstly try fetch via external urls + for _, us := range desc.URLs { + ctx = log.WithLogger(ctx, log.G(ctx).WithField("url", us)) + + u, err := url.Parse(us) + if err != nil { + log.G(ctx).WithError(err).Debug("failed to parse") + continue + } + log.G(ctx).Debug("trying alternative url") + + // Try this first, parse it + host := RegistryHost{ + Client: http.DefaultClient, + Host: u.Host, + Scheme: u.Scheme, + Path: u.Path, + Capabilities: HostCapabilityPull, + } + req := r.request(host, http.MethodGet) + // Strip namespace from base + req.path = u.Path + if u.RawQuery != "" { + req.path = req.path + "?" + u.RawQuery + } + + rc, err := r.open(ctx, req, desc.MediaType, offset) if err != nil { if errdefs.IsNotFound(err) { continue // try one of the other urls. @@ -71,6 +90,44 @@ func (r dockerFetcher) Fetch(ctx context.Context, desc ocispec.Descriptor) (io.R return rc, nil } + // Try manifests endpoints for manifests types + switch desc.MediaType { + case images.MediaTypeDockerSchema2Manifest, images.MediaTypeDockerSchema2ManifestList, + images.MediaTypeDockerSchema1Manifest, + ocispec.MediaTypeImageManifest, ocispec.MediaTypeImageIndex: + + for _, host := range r.hosts { + req := r.request(host, http.MethodGet, "manifests", desc.Digest.String()) + + rc, err := r.open(ctx, req, desc.MediaType, offset) + if err != nil { + if errdefs.IsNotFound(err) { + continue // try another host + } + + return nil, err + } + + return rc, nil + } + } + + // Finally use blobs endpoints + for _, host := range r.hosts { + req := r.request(host, http.MethodGet, "blobs", desc.Digest.String()) + + rc, err := r.open(ctx, req, desc.MediaType, offset) + if err != nil { + if errdefs.IsNotFound(err) { + continue // try another host + } + + return nil, err + } + + return rc, nil + } + return nil, errors.Wrapf(errdefs.ErrNotFound, "could not fetch content descriptor %v (%v) from remote", desc.Digest, desc.MediaType) @@ -78,22 +135,17 @@ func (r dockerFetcher) Fetch(ctx context.Context, desc ocispec.Descriptor) (io.R }) } -func (r dockerFetcher) open(ctx context.Context, u, mediatype string, offset int64) (io.ReadCloser, error) { - req, err := http.NewRequest(http.MethodGet, u, nil) - if err != nil { - return nil, err - } - - req.Header.Set("Accept", strings.Join([]string{mediatype, `*`}, ", ")) +func (r dockerFetcher) open(ctx context.Context, req *request, mediatype string, offset int64) (io.ReadCloser, error) { + req.header.Set("Accept", strings.Join([]string{mediatype, `*`}, ", ")) if offset > 0 { // Note: "Accept-Ranges: bytes" cannot be trusted as some endpoints // will return the header without supporting the range. The content // range must always be checked. - req.Header.Set("Range", fmt.Sprintf("bytes=%d-", offset)) + req.header.Set("Range", fmt.Sprintf("bytes=%d-", offset)) } - resp, err := r.doRequestWithRetries(ctx, req, nil) + resp, err := req.doWithRetries(ctx, nil) if err != nil { return nil, err } @@ -106,13 +158,13 @@ func (r dockerFetcher) open(ctx context.Context, u, mediatype string, offset int defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { - return nil, errors.Wrapf(errdefs.ErrNotFound, "content at %v not found", u) + return nil, errors.Wrapf(errdefs.ErrNotFound, "content at %v not found", req.String()) } var registryErr errcode.Errors if err := json.NewDecoder(resp.Body).Decode(®istryErr); err != nil || registryErr.Len() < 1 { - return nil, errors.Errorf("unexpected status code %v: %v", u, resp.Status) + return nil, errors.Errorf("unexpected status code %v: %v", req.String(), resp.Status) } - return nil, errors.Errorf("unexpected status code %v: %s - Server message: %s", u, resp.Status, registryErr.Error()) + return nil, errors.Errorf("unexpected status code %v: %s - Server message: %s", req.String(), resp.Status, registryErr.Error()) } if offset > 0 { cr := resp.Header.Get("content-range") @@ -141,30 +193,3 @@ func (r dockerFetcher) open(ctx context.Context, u, mediatype string, offset int return resp.Body, nil } - -// getV2URLPaths generates the candidate urls paths for the object based on the -// set of hints and the provided object id. URLs are returned in the order of -// most to least likely succeed. -func (r *dockerFetcher) getV2URLPaths(ctx context.Context, desc ocispec.Descriptor) ([]string, error) { - var urls []string - - if len(desc.URLs) > 0 { - // handle fetch via external urls. - for _, u := range desc.URLs { - log.G(ctx).WithField("url", u).Debug("adding alternative url") - urls = append(urls, u) - } - } - - switch desc.MediaType { - case images.MediaTypeDockerSchema2Manifest, images.MediaTypeDockerSchema2ManifestList, - images.MediaTypeDockerSchema1Manifest, - ocispec.MediaTypeImageManifest, ocispec.MediaTypeImageIndex: - urls = append(urls, r.url(path.Join("manifests", desc.Digest.String()))) - } - - // always fallback to attempting to get the object out of the blobs store. - urls = append(urls, r.url(path.Join("blobs", desc.Digest.String()))) - - return urls, nil -} diff --git a/remotes/docker/fetcher_test.go b/remotes/docker/fetcher_test.go index 22fe8c181..701a6b345 100644 --- a/remotes/docker/fetcher_test.go +++ b/remotes/docker/fetcher_test.go @@ -25,6 +25,7 @@ import ( "math/rand" "net/http" "net/http/httptest" + "net/url" "testing" "github.com/docker/distribution/registry/api/errcode" @@ -46,14 +47,30 @@ func TestFetcherOpen(t *testing.T) { })) defer s.Close() + u, err := url.Parse(s.URL) + if err != nil { + t.Fatal(err) + } + f := dockerFetcher{&dockerBase{ - client: s.Client(), + namespace: "nonempty", }} + + host := RegistryHost{ + Client: s.Client(), + Host: u.Host, + Scheme: u.Scheme, + Path: u.Path, + } + ctx := context.Background() + req := f.request(host, http.MethodGet) + checkReader := func(o int64) { t.Helper() - rc, err := f.open(ctx, s.URL, "", o) + + rc, err := f.open(ctx, req, "", o) if err != nil { t.Fatalf("failed to open: %+v", err) } @@ -93,7 +110,7 @@ func TestFetcherOpen(t *testing.T) { // Check that server returning a different content range // then requested errors start = 30 - _, err := f.open(ctx, s.URL, "", 20) + _, err = f.open(ctx, req, "", 20) if err == nil { t.Fatal("expected error opening with invalid server response") } @@ -160,20 +177,34 @@ func TestDockerFetcherOpen(t *testing.T) { })) defer s.Close() - r := dockerFetcher{&dockerBase{ - client: s.Client(), + u, err := url.Parse(s.URL) + if err != nil { + t.Fatal(err) + } + + f := dockerFetcher{&dockerBase{ + namespace: "ns", }} - got, err := r.open(context.TODO(), s.URL, "", 0) + host := RegistryHost{ + Client: s.Client(), + Host: u.Host, + Scheme: u.Scheme, + Path: u.Path, + } + + req := f.request(host, http.MethodGet) + + got, err := f.open(context.TODO(), req, "", 0) assert.Equal(t, tt.wantErr, (err != nil)) assert.Equal(t, tt.want, got) assert.Equal(t, tt.retries, 0) if tt.wantErr { var expectedError error if tt.wantServerMessageError { - expectedError = errors.Errorf("unexpected status code %v: %v %s - Server message: %s", s.URL, tt.mockedStatus, http.StatusText(tt.mockedStatus), tt.mockedErr.Error()) + expectedError = errors.Errorf("unexpected status code %v/ns: %v %s - Server message: %s", s.URL, tt.mockedStatus, http.StatusText(tt.mockedStatus), tt.mockedErr.Error()) } else if tt.wantPlainError { - expectedError = errors.Errorf("unexpected status code %v: %v %s", s.URL, tt.mockedStatus, http.StatusText(tt.mockedStatus)) + expectedError = errors.Errorf("unexpected status code %v/ns: %v %s", s.URL, tt.mockedStatus, http.StatusText(tt.mockedStatus)) } assert.Equal(t, expectedError.Error(), err.Error()) diff --git a/remotes/docker/pusher.go b/remotes/docker/pusher.go index af8a77427..f7787a019 100644 --- a/remotes/docker/pusher.go +++ b/remotes/docker/pusher.go @@ -21,7 +21,7 @@ import ( "io" "io/ioutil" "net/http" - "path" + "net/url" "strings" "time" @@ -59,9 +59,15 @@ func (p dockerPusher) Push(ctx context.Context, desc ocispec.Descriptor) (conten return nil, errors.Wrap(err, "failed to get status") } + hosts := p.filterHosts(HostCapabilityPush) + if len(hosts) == 0 { + return nil, errors.Wrap(errdefs.ErrNotFound, "no push hosts") + } + var ( isManifest bool - existCheck string + existCheck []string + host = hosts[0] ) switch desc.MediaType { @@ -69,21 +75,20 @@ func (p dockerPusher) Push(ctx context.Context, desc ocispec.Descriptor) (conten ocispec.MediaTypeImageManifest, ocispec.MediaTypeImageIndex: isManifest = true if p.tag == "" { - existCheck = path.Join("manifests", desc.Digest.String()) + existCheck = []string{"manifests", desc.Digest.String()} } else { - existCheck = path.Join("manifests", p.tag) + existCheck = []string{"manifests", p.tag} } default: - existCheck = path.Join("blobs", desc.Digest.String()) + existCheck = []string{"blobs", desc.Digest.String()} } - req, err := http.NewRequest(http.MethodHead, p.url(existCheck), nil) - if err != nil { - return nil, err - } + req := p.request(host, http.MethodHead, existCheck...) + req.header.Set("Accept", strings.Join([]string{desc.MediaType, `*`}, ", ")) - req.Header.Set("Accept", strings.Join([]string{desc.MediaType, `*`}, ", ")) - resp, err := p.doRequestWithRetries(ctx, req, nil) + log.G(ctx).WithField("url", req.String()).Debugf("checking and pushing to") + + resp, err := req.doWithRetries(ctx, nil) if err != nil { if errors.Cause(err) != ErrInvalidAuthorization { return nil, err @@ -117,28 +122,22 @@ func (p dockerPusher) Push(ctx context.Context, desc ocispec.Descriptor) (conten } if isManifest { - var putPath string + var putPath []string if p.tag != "" { - putPath = path.Join("manifests", p.tag) + putPath = []string{"manifests", p.tag} } else { - putPath = path.Join("manifests", desc.Digest.String()) + putPath = []string{"manifests", desc.Digest.String()} } - req, err = http.NewRequest(http.MethodPut, p.url(putPath), nil) - if err != nil { - return nil, err - } - req.Header.Add("Content-Type", desc.MediaType) + req = p.request(host, http.MethodPut, putPath...) + req.header.Add("Content-Type", desc.MediaType) } else { // Start upload request - req, err = http.NewRequest(http.MethodPost, p.url("blobs", "uploads")+"/", nil) - if err != nil { - return nil, err - } + req = p.request(host, http.MethodPost, "blobs", "uploads/") var resp *http.Response if fromRepo := selectRepositoryMountCandidate(p.refspec, desc.Annotations); fromRepo != "" { - req = requestWithMountFrom(req, desc.Digest.String(), fromRepo) + preq := requestWithMountFrom(req, desc.Digest.String(), fromRepo) pctx := contextWithAppendPullRepositoryScope(ctx, fromRepo) // NOTE: the fromRepo might be private repo and @@ -147,7 +146,8 @@ func (p dockerPusher) Push(ctx context.Context, desc ocispec.Descriptor) (conten // // for the private repo, we should remove mount-from // query and send the request again. - resp, err = p.doRequest(pctx, req) + resp, err = preq.do(pctx) + //resp, err = p.doRequest(pctx, req) if err != nil { return nil, err } @@ -157,16 +157,11 @@ func (p dockerPusher) Push(ctx context.Context, desc ocispec.Descriptor) (conten resp.Body.Close() resp = nil - - req, err = removeMountFromQuery(req) - if err != nil { - return nil, err - } } } if resp == nil { - resp, err = p.doRequestWithRetries(ctx, req, nil) + resp, err = req.doWithRetries(ctx, nil) if err != nil { return nil, err } @@ -186,31 +181,41 @@ func (p dockerPusher) Push(ctx context.Context, desc ocispec.Descriptor) (conten return nil, errors.Errorf("unexpected response: %s", resp.Status) } - location := resp.Header.Get("Location") + var ( + location = resp.Header.Get("Location") + lurl *url.URL + lhost = host + ) // Support paths without host in location if strings.HasPrefix(location, "/") { - // Support location string containing path and query - qmIndex := strings.Index(location, "?") - if qmIndex > 0 { - u := p.base - u.Path = location[:qmIndex] - u.RawQuery = location[qmIndex+1:] - location = u.String() - } else { - u := p.base - u.Path = location - location = u.String() + lurl, err = url.Parse(lhost.Scheme + "://" + lhost.Host + location) + if err != nil { + return nil, errors.Wrapf(err, "unable to parse location %v", location) + } + } else { + if !strings.Contains(location, "://") { + location = lhost.Scheme + "://" + location + } + lurl, err = url.Parse(location) + if err != nil { + return nil, errors.Wrapf(err, "unable to parse location %v", location) + } + + if lurl.Host != lhost.Host || lhost.Scheme != lurl.Scheme { + + lhost.Scheme = lurl.Scheme + lhost.Host = lurl.Host + log.G(ctx).WithField("host", lhost.Host).WithField("scheme", lhost.Scheme).Debug("upload changed destination") + + // Strip authorizer if change to host or scheme + lhost.Authorizer = nil } } - - req, err = http.NewRequest(http.MethodPut, location, nil) - if err != nil { - return nil, err - } - q := req.URL.Query() + q := lurl.Query() q.Add("digest", desc.Digest.String()) - req.URL.RawQuery = q.Encode() + req = p.request(lhost, http.MethodPut) + req.path = lurl.Path + "?" + q.Encode() } p.tracker.SetStatus(ref, Status{ Status: content.Status{ @@ -226,12 +231,14 @@ func (p dockerPusher) Push(ctx context.Context, desc ocispec.Descriptor) (conten pr, pw := io.Pipe() respC := make(chan *http.Response, 1) - req.Body = ioutil.NopCloser(pr) - req.ContentLength = desc.Size + req.body = func() (io.ReadCloser, error) { + return ioutil.NopCloser(pr), nil + } + req.size = desc.Size go func() { defer close(respC) - resp, err = p.doRequest(ctx, req) + resp, err = req.do(ctx) if err != nil { pr.CloseWithError(err) return @@ -355,24 +362,15 @@ func (pw *pushWriter) Truncate(size int64) error { return errors.New("cannot truncate remote upload") } -func requestWithMountFrom(req *http.Request, mount, from string) *http.Request { - q := req.URL.Query() +func requestWithMountFrom(req *request, mount, from string) *request { + creq := *req - q.Set("mount", mount) - q.Set("from", from) - req.URL.RawQuery = q.Encode() - return req -} - -func removeMountFromQuery(req *http.Request) (*http.Request, error) { - req, err := copyRequest(req) - if err != nil { - return nil, err + sep := "?" + if strings.Contains(creq.path, sep) { + sep = "&" } - q := req.URL.Query() - q.Del("mount") - q.Del("from") - req.URL.RawQuery = q.Encode() - return req, nil + creq.path = creq.path + sep + "mount=" + mount + "&from=" + from + + return &creq } diff --git a/remotes/docker/registry.go b/remotes/docker/registry.go new file mode 100644 index 000000000..ae24f41e1 --- /dev/null +++ b/remotes/docker/registry.go @@ -0,0 +1,202 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package docker + +import ( + "net/http" +) + +// HostCapabilities represent the capabilities of the registry +// host. This also represents the set of operations for which +// the registry host may be trusted to perform. +// +// For example pushing is a capability which should only be +// performed on an upstream source, not a mirror. +// Resolving (the process of converting a name into a digest) +// must be considered a trusted operation and only done by +// a host which is trusted (or more preferably by secure process +// which can prove the provenance of the mapping). A public +// mirror should never be trusted to do a resolve action. +// +// | Registry Type | Pull | Resolve | Push | +// |------------------|------|---------|------| +// | Public Registry | yes | yes | yes | +// | Private Registry | yes | yes | yes | +// | Public Mirror | yes | no | no | +// | Private Mirror | yes | yes | no | +type HostCapabilities uint8 + +const ( + // HostCapabilityPull represents the capability to fetch manifests + // and blobs by digest + HostCapabilityPull HostCapabilities = 1 << iota + + // HostCapabilityResolve represents the capability to fetch manifests + // by name + HostCapabilityResolve + + // HostCapabilityPush represents the capability to push blobs and + // manifests + HostCapabilityPush + + // Reserved for future capabilities (i.e. search, catalog, remove) +) + +func (c HostCapabilities) Has(t HostCapabilities) bool { + return c&t == t +} + +// RegistryHost represents a complete configuration for a registry +// host, representing the capabilities, authorizations, connection +// configuration, and location. +type RegistryHost struct { + Client *http.Client + Authorizer Authorizer + Host string + Scheme string + Path string + Capabilities HostCapabilities +} + +// RegistryHosts fetches the registry hosts for a given namespace, +// provided by the host component of an distribution image reference. +type RegistryHosts func(string) ([]RegistryHost, error) + +// Registries joins multiple registry configuration functions, using the same +// order as provided within the arguments. When an empty registry configuration +// is returned with a nil error, the next function will be called. +// NOTE: This function will not join configurations, as soon as a non-empty +// configuration is returned from a configuration function, it will be returned +// to the caller. +func Registries(registries ...RegistryHosts) RegistryHosts { + return func(host string) ([]RegistryHost, error) { + for _, registry := range registries { + config, err := registry(host) + if err != nil { + return config, err + } + if len(config) > 0 { + return config, nil + } + } + return nil, nil + } +} + +type registryOpts struct { + authorizer Authorizer + plainHTTP func(string) (bool, error) + host func(string) (string, error) + client *http.Client +} + +// RegistryOpt defines a registry default option +type RegistryOpt func(*registryOpts) + +// WithPlainHTTP configures registries to use plaintext http scheme +// for the provided host match function. +func WithPlainHTTP(f func(string) (bool, error)) RegistryOpt { + return func(opts *registryOpts) { + opts.plainHTTP = f + } +} + +// WithAuthorizer configures the default authorizer for a registry +func WithAuthorizer(a Authorizer) RegistryOpt { + return func(opts *registryOpts) { + opts.authorizer = a + } +} + +// WithHostTranslator defines the default translator to use for registry hosts +func WithHostTranslator(h func(string) (string, error)) RegistryOpt { + return func(opts *registryOpts) { + opts.host = h + } +} + +// WithClient configures the default http client for a registry +func WithClient(c *http.Client) RegistryOpt { + return func(opts *registryOpts) { + opts.client = c + } +} + +// ConfigureDefaultRegistries is used to create a default configuration for +// registries. For more advanced configurations or per-domain setups, +// the RegistryHosts interface should be used directly. +// NOTE: This function will always return a non-empty value or error +func ConfigureDefaultRegistries(ropts ...RegistryOpt) RegistryHosts { + var opts registryOpts + for _, opt := range ropts { + opt(&opts) + } + + return func(host string) ([]RegistryHost, error) { + config := RegistryHost{ + Client: opts.client, + Authorizer: opts.authorizer, + Host: host, + Scheme: "https", + Path: "/v2", + Capabilities: HostCapabilityPull | HostCapabilityResolve | HostCapabilityPush, + } + + if config.Client == nil { + config.Client = http.DefaultClient + } + + if opts.plainHTTP != nil { + match, err := opts.plainHTTP(host) + if err != nil { + return nil, err + } + if match { + config.Scheme = "http" + } + } + + if opts.host != nil { + var err error + config.Host, err = opts.host(config.Host) + if err != nil { + return nil, err + } + } else if host == "docker.io" { + config.Host = "registry-1.docker.io" + } + + return []RegistryHost{config}, nil + } +} + +// MatchAllHosts is a host match function which is always true. +func MatchAllHosts(string) (bool, error) { + return true, nil +} + +// MatchLocalhost is a host match function which returns true for +// localhost. +func MatchLocalhost(host string) (bool, error) { + for _, s := range []string{"localhost", "127.0.0.1", "[::1]"} { + if len(host) >= len(s) && host[0:len(s)] == s && (len(host) == len(s) || host[len(s)] == ':') { + return true, nil + } + } + return host == "::1", nil + +} diff --git a/remotes/docker/registry_test.go b/remotes/docker/registry_test.go new file mode 100644 index 000000000..0dfe178f1 --- /dev/null +++ b/remotes/docker/registry_test.go @@ -0,0 +1,76 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package docker + +import "testing" + +func TestHasCapability(t *testing.T) { + var ( + pull = HostCapabilityPull + rslv = HostCapabilityResolve + push = HostCapabilityPush + all = pull | rslv | push + ) + for i, tc := range []struct { + c HostCapabilities + t HostCapabilities + e bool + }{ + {all, pull, true}, + {all, pull | rslv, true}, + {all, pull | push, true}, + {all, all, true}, + {pull, all, false}, + {pull, push, false}, + {rslv, pull, false}, + {pull | rslv, push, false}, + {pull | rslv, rslv, true}, + } { + if a := tc.c.Has(tc.t); a != tc.e { + t.Fatalf("%d: failed, expected %t, got %t", i, tc.e, a) + } + } +} + +func TestMatchLocalhost(t *testing.T) { + for _, tc := range []struct { + host string + match bool + }{ + {"", false}, + {"127.1.1.1", false}, + {"127.0.0.1", true}, + {"127.0.0.1:5000", true}, + {"registry.org", false}, + {"localhost", true}, + {"localhost:5000", true}, + {"[127:0:0:1]", false}, + {"[::1]", true}, + {"[::1]:5000", true}, + {"::1", true}, + } { + actual, _ := MatchLocalhost(tc.host) + if actual != tc.match { + if tc.match { + t.Logf("Expected match for %s", tc.host) + } else { + t.Logf("Unexpected match for %s", tc.host) + } + t.Fail() + } + } +} diff --git a/remotes/docker/resolver.go b/remotes/docker/resolver.go index 74d1f4212..d7b8627ce 100644 --- a/remotes/docker/resolver.go +++ b/remotes/docker/resolver.go @@ -18,9 +18,10 @@ package docker import ( "context" + "fmt" "io" + "io/ioutil" "net/http" - "net/url" "path" "strings" @@ -46,6 +47,19 @@ var ( // ErrInvalidAuthorization is used when credentials are passed to a server but // those credentials are rejected. ErrInvalidAuthorization = errors.New("authorization failed") + + // MaxManifestSize represents the largest size accepted from a registry + // during resolution. Larger manifests may be accepted using a + // resolution method other than the registry. + // + // NOTE: The max supported layers by some runtimes is 128 and individual + // layers will not contribute more than 256 bytes, making a + // reasonable limit for a large image manifests of 32K bytes. + // 4M bytes represents a much larger upper bound for images which may + // contain large annotations or be non-images. A proper manifest + // design puts large metadata in subobjects, as is consistent the + // intent of the manifest design. + MaxManifestSize int64 = 4 * 1048 * 1048 ) // Authorizer is used to authorize HTTP requests based on 401 HTTP responses. @@ -72,31 +86,38 @@ type Authorizer interface { // ResolverOptions are used to configured a new Docker register resolver type ResolverOptions struct { - // Authorizer is used to authorize registry requests - Authorizer Authorizer - - // Credentials provides username and secret given a host. - // If username is empty but a secret is given, that secret - // is interpreted as a long lived token. - // Deprecated: use Authorizer - Credentials func(string) (string, string, error) - - // Host provides the hostname given a namespace. - Host func(string) (string, error) + // Hosts returns registry host configurations for a namespace. + Hosts RegistryHosts // Headers are the HTTP request header fields sent by the resolver Headers http.Header - // PlainHTTP specifies to use plain http and not https - PlainHTTP bool - - // Client is the http client to used when making registry requests - Client *http.Client - // Tracker is used to track uploads to the registry. This is used // since the registry does not have upload tracking and the existing // mechanism for getting blob upload status is expensive. Tracker StatusTracker + + // Authorizer is used to authorize registry requests + // Deprecated: use Hosts + Authorizer Authorizer + + // Credentials provides username and secret given a host. + // If username is empty but a secret is given, that secret + // is interpreted as a long lived token. + // Deprecated: use Hosts + Credentials func(string) (string, string, error) + + // Host provides the hostname given a namespace. + // Deprecated: use Hosts + Host func(string) (string, error) + + // PlainHTTP specifies to use plain http and not https + // Deprecated: use Hosts + PlainHTTP bool + + // Client is the http client to used when making registry requests + // Deprecated: use Hosts + Client *http.Client } // DefaultHost is the default host function. @@ -108,13 +129,10 @@ func DefaultHost(ns string) (string, error) { } type dockerResolver struct { - auth Authorizer - host func(string) (string, error) - headers http.Header - uagent string - plainHTTP bool - client *http.Client - tracker StatusTracker + hosts RegistryHosts + header http.Header + resolveHeader http.Header + tracker StatusTracker } // NewResolver returns a new resolver to a Docker registry @@ -122,39 +140,56 @@ func NewResolver(options ResolverOptions) remotes.Resolver { if options.Tracker == nil { options.Tracker = NewInMemoryTracker() } - if options.Host == nil { - options.Host = DefaultHost - } + if options.Headers == nil { options.Headers = make(http.Header) } + if _, ok := options.Headers["User-Agent"]; !ok { + options.Headers.Set("User-Agent", "containerd/"+version.Version) + } + + resolveHeader := http.Header{} if _, ok := options.Headers["Accept"]; !ok { // set headers for all the types we support for resolution. - options.Headers.Set("Accept", strings.Join([]string{ + resolveHeader.Set("Accept", strings.Join([]string{ images.MediaTypeDockerSchema2Manifest, images.MediaTypeDockerSchema2ManifestList, ocispec.MediaTypeImageManifest, ocispec.MediaTypeImageIndex, "*"}, ", ")) - } - ua := options.Headers.Get("User-Agent") - if ua != "" { - options.Headers.Del("User-Agent") } else { - ua = "containerd/" + version.Version + resolveHeader["Accept"] = options.Headers["Accept"] + delete(options.Headers, "Accept") } - if options.Authorizer == nil { - options.Authorizer = NewAuthorizer(options.Client, options.Credentials) - options.Authorizer.(*dockerAuthorizer).ua = ua + if options.Hosts == nil { + opts := []RegistryOpt{} + if options.Host != nil { + opts = append(opts, WithHostTranslator(options.Host)) + } + + if options.Authorizer == nil { + options.Authorizer = NewDockerAuthorizer( + WithAuthClient(options.Client), + WithAuthHeader(options.Headers), + WithAuthCreds(options.Credentials)) + } + opts = append(opts, WithAuthorizer(options.Authorizer)) + + if options.Client != nil { + opts = append(opts, WithClient(options.Client)) + } + if options.PlainHTTP { + opts = append(opts, WithPlainHTTP(MatchAllHosts)) + } else { + opts = append(opts, WithPlainHTTP(MatchLocalhost)) + } + options.Hosts = ConfigureDefaultRegistries(opts...) } return &dockerResolver{ - auth: options.Authorizer, - host: options.Host, - headers: options.Headers, - uagent: ua, - plainHTTP: options.PlainHTTP, - client: options.Client, - tracker: options.Tracker, + hosts: options.Hosts, + header: options.Headers, + resolveHeader: resolveHeader, + tracker: options.Tracker, } } @@ -201,13 +236,11 @@ func (r *dockerResolver) Resolve(ctx context.Context, ref string) (string, ocisp return "", ocispec.Descriptor{}, err } - fetcher := dockerFetcher{ - dockerBase: base, - } - var ( - urls []string - dgst = refspec.Digest() + lastErr error + paths [][]string + dgst = refspec.Digest() + caps = HostCapabilityPull ) if dgst != "" { @@ -218,100 +251,130 @@ func (r *dockerResolver) Resolve(ctx context.Context, ref string) (string, ocisp } // turns out, we have a valid digest, make a url. - urls = append(urls, fetcher.url("manifests", dgst.String())) + paths = append(paths, []string{"manifests", dgst.String()}) // fallback to blobs on not found. - urls = append(urls, fetcher.url("blobs", dgst.String())) + paths = append(paths, []string{"blobs", dgst.String()}) } else { - urls = append(urls, fetcher.url("manifests", refspec.Object)) + // Add + paths = append(paths, []string{"manifests", refspec.Object}) + caps |= HostCapabilityResolve + } + + hosts := base.filterHosts(caps) + if len(hosts) == 0 { + return "", ocispec.Descriptor{}, errors.Wrap(errdefs.ErrNotFound, "no resolve hosts") } ctx, err = contextWithRepositoryScope(ctx, refspec, false) if err != nil { return "", ocispec.Descriptor{}, err } - for _, u := range urls { - req, err := http.NewRequest(http.MethodHead, u, nil) - if err != nil { - return "", ocispec.Descriptor{}, err - } - req.Header = r.headers + for _, u := range paths { + for _, host := range hosts { + ctx := log.WithLogger(ctx, log.G(ctx).WithField("host", host.Host)) - log.G(ctx).Debug("resolving") - resp, err := fetcher.doRequestWithRetries(ctx, req, nil) - if err != nil { - if errors.Cause(err) == ErrInvalidAuthorization { - err = errors.Wrapf(err, "pull access denied, repository does not exist or may require authorization") + req := base.request(host, http.MethodHead, u...) + for key, value := range r.resolveHeader { + req.header[key] = append(req.header[key], value...) } - return "", ocispec.Descriptor{}, err - } - resp.Body.Close() // don't care about body contents. - if resp.StatusCode > 299 { - if resp.StatusCode == http.StatusNotFound { + log.G(ctx).Debug("resolving") + resp, err := req.doWithRetries(ctx, nil) + if err != nil { + if errors.Cause(err) == ErrInvalidAuthorization { + err = errors.Wrapf(err, "pull access denied, repository does not exist or may require authorization") + } + return "", ocispec.Descriptor{}, err + } + resp.Body.Close() // don't care about body contents. + + if resp.StatusCode > 299 { + if resp.StatusCode == http.StatusNotFound { + continue + } + return "", ocispec.Descriptor{}, errors.Errorf("unexpected status code %v: %v", u, resp.Status) + } + size := resp.ContentLength + contentType := getManifestMediaType(resp) + + // if no digest was provided, then only a resolve + // trusted registry was contacted, in this case use + // the digest header (or content from GET) + if dgst == "" { + // this is the only point at which we trust the registry. we use the + // content headers to assemble a descriptor for the name. when this becomes + // more robust, we mostly get this information from a secure trust store. + dgstHeader := digest.Digest(resp.Header.Get("Docker-Content-Digest")) + + if dgstHeader != "" && size != -1 { + if err := dgstHeader.Validate(); err != nil { + return "", ocispec.Descriptor{}, errors.Wrapf(err, "%q in header not a valid digest", dgstHeader) + } + dgst = dgstHeader + } + } + if dgst == "" || size == -1 { + log.G(ctx).Debug("no Docker-Content-Digest header, fetching manifest instead") + + req = base.request(host, http.MethodGet, u...) + for key, value := range r.resolveHeader { + req.header[key] = append(req.header[key], value...) + } + + resp, err := req.doWithRetries(ctx, nil) + if err != nil { + return "", ocispec.Descriptor{}, err + } + defer resp.Body.Close() + + bodyReader := countingReader{reader: resp.Body} + + contentType = getManifestMediaType(resp) + if dgst == "" { + if contentType == images.MediaTypeDockerSchema1Manifest { + b, err := schema1.ReadStripSignature(&bodyReader) + if err != nil { + return "", ocispec.Descriptor{}, err + } + + dgst = digest.FromBytes(b) + } else { + dgst, err = digest.FromReader(&bodyReader) + if err != nil { + return "", ocispec.Descriptor{}, err + } + } + } else if _, err := io.Copy(ioutil.Discard, &bodyReader); err != nil { + return "", ocispec.Descriptor{}, err + } + size = bodyReader.bytesRead + } + // Prevent resolving to excessively large manifests + if size > MaxManifestSize { + if lastErr == nil { + lastErr = errors.Wrapf(errdefs.ErrNotFound, "rejecting %d byte manifest for %s", size, ref) + } continue } - return "", ocispec.Descriptor{}, errors.Errorf("unexpected status code %v: %v", u, resp.Status) + + desc := ocispec.Descriptor{ + Digest: dgst, + MediaType: contentType, + Size: size, + } + + log.G(ctx).WithField("desc.digest", desc.Digest).Debug("resolved") + return ref, desc, nil } - size := resp.ContentLength - - // this is the only point at which we trust the registry. we use the - // content headers to assemble a descriptor for the name. when this becomes - // more robust, we mostly get this information from a secure trust store. - dgstHeader := digest.Digest(resp.Header.Get("Docker-Content-Digest")) - contentType := getManifestMediaType(resp) - - if dgstHeader != "" && size != -1 { - if err := dgstHeader.Validate(); err != nil { - return "", ocispec.Descriptor{}, errors.Wrapf(err, "%q in header not a valid digest", dgstHeader) - } - dgst = dgstHeader - } else { - log.G(ctx).Debug("no Docker-Content-Digest header, fetching manifest instead") - - req, err := http.NewRequest(http.MethodGet, u, nil) - if err != nil { - return "", ocispec.Descriptor{}, err - } - req.Header = r.headers - - resp, err := fetcher.doRequestWithRetries(ctx, req, nil) - if err != nil { - return "", ocispec.Descriptor{}, err - } - defer resp.Body.Close() - - bodyReader := countingReader{reader: resp.Body} - - contentType = getManifestMediaType(resp) - if contentType == images.MediaTypeDockerSchema1Manifest { - b, err := schema1.ReadStripSignature(&bodyReader) - if err != nil { - return "", ocispec.Descriptor{}, err - } - - dgst = digest.FromBytes(b) - } else { - dgst, err = digest.FromReader(&bodyReader) - if err != nil { - return "", ocispec.Descriptor{}, err - } - } - size = bodyReader.bytesRead - } - - desc := ocispec.Descriptor{ - Digest: dgst, - MediaType: contentType, - Size: size, - } - - log.G(ctx).WithField("desc.digest", desc.Digest).Debug("resolved") - return ref, desc, nil } - return "", ocispec.Descriptor{}, errors.Errorf("%v not found", ref) + if lastErr == nil { + lastErr = errors.Wrap(errdefs.ErrNotFound, ref) + } + + return "", ocispec.Descriptor{}, lastErr } func (r *dockerResolver) Fetcher(ctx context.Context, ref string) (remotes.Fetcher, error) { @@ -356,56 +419,58 @@ func (r *dockerResolver) Pusher(ctx context.Context, ref string) (remotes.Pusher } type dockerBase struct { - refspec reference.Spec - base url.URL - uagent string - - client *http.Client - auth Authorizer + refspec reference.Spec + namespace string + hosts []RegistryHost + header http.Header } func (r *dockerResolver) base(refspec reference.Spec) (*dockerBase, error) { - var ( - err error - base url.URL - ) - host := refspec.Hostname() - base.Host = host - if r.host != nil { - base.Host, err = r.host(host) - if err != nil { - return nil, err - } + hosts, err := r.hosts(host) + if err != nil { + return nil, err } - - base.Scheme = "https" - if r.plainHTTP || strings.HasPrefix(base.Host, "localhost:") { - base.Scheme = "http" - } - - prefix := strings.TrimPrefix(refspec.Locator, host+"/") - base.Path = path.Join("/v2", prefix) - return &dockerBase{ - refspec: refspec, - base: base, - uagent: r.uagent, - client: r.client, - auth: r.auth, + refspec: refspec, + namespace: strings.TrimPrefix(refspec.Locator, host+"/"), + hosts: hosts, + header: r.header, }, nil } -func (r *dockerBase) url(ps ...string) string { - url := r.base - url.Path = path.Join(url.Path, path.Join(ps...)) - return url.String() +func (r *dockerBase) filterHosts(caps HostCapabilities) (hosts []RegistryHost) { + for _, host := range r.hosts { + if host.Capabilities.Has(caps) { + hosts = append(hosts, host) + } + } + return } -func (r *dockerBase) authorize(ctx context.Context, req *http.Request) error { +func (r *dockerBase) request(host RegistryHost, method string, ps ...string) *request { + header := http.Header{} + for key, value := range r.header { + header[key] = append(header[key], value...) + } + parts := append([]string{"/", host.Path, r.namespace}, ps...) + p := path.Join(parts...) + // Join strips trailing slash, re-add ending "/" if included + if len(parts) > 0 && strings.HasSuffix(parts[len(parts)-1], "/") { + p = p + "/" + } + return &request{ + method: method, + path: p, + header: header, + host: host, + } +} + +func (r *request) authorize(ctx context.Context, req *http.Request) error { // Check if has header for host - if r.auth != nil { - if err := r.auth.Authorize(ctx, req); err != nil { + if r.host.Authorizer != nil { + if err := r.host.Authorizer.Authorize(ctx, req); err != nil { return err } } @@ -413,83 +478,132 @@ func (r *dockerBase) authorize(ctx context.Context, req *http.Request) error { return nil } -func (r *dockerBase) doRequest(ctx context.Context, req *http.Request) (*http.Response, error) { - ctx = log.WithLogger(ctx, log.G(ctx).WithField("url", req.URL.String())) - log.G(ctx).WithField("request.headers", req.Header).WithField("request.method", req.Method).Debug("do request") - req.Header.Set("User-Agent", r.uagent) +type request struct { + method string + path string + header http.Header + host RegistryHost + body func() (io.ReadCloser, error) + size int64 +} + +func (r *request) do(ctx context.Context) (*http.Response, error) { + u := r.host.Scheme + "://" + r.host.Host + r.path + req, err := http.NewRequest(r.method, u, nil) + if err != nil { + return nil, err + } + req.Header = r.header + if r.body != nil { + req.GetBody = r.body + if r.size > 0 { + req.ContentLength = r.size + } + } + + ctx = log.WithLogger(ctx, log.G(ctx).WithField("url", u)) + log.G(ctx).WithFields(requestFields(req)).Debug("do request") if err := r.authorize(ctx, req); err != nil { return nil, errors.Wrap(err, "failed to authorize") } - resp, err := ctxhttp.Do(ctx, r.client, req) + resp, err := ctxhttp.Do(ctx, r.host.Client, req) if err != nil { return nil, errors.Wrap(err, "failed to do request") } - log.G(ctx).WithFields(logrus.Fields{ - "status": resp.Status, - "response.headers": resp.Header, - }).Debug("fetch response received") + log.G(ctx).WithFields(responseFields(resp)).Debug("fetch response received") return resp, nil } -func (r *dockerBase) doRequestWithRetries(ctx context.Context, req *http.Request, responses []*http.Response) (*http.Response, error) { - resp, err := r.doRequest(ctx, req) +func (r *request) doWithRetries(ctx context.Context, responses []*http.Response) (*http.Response, error) { + resp, err := r.do(ctx) if err != nil { return nil, err } responses = append(responses, resp) - req, err = r.retryRequest(ctx, req, responses) + retry, err := r.retryRequest(ctx, responses) if err != nil { resp.Body.Close() return nil, err } - if req != nil { + if retry { resp.Body.Close() - return r.doRequestWithRetries(ctx, req, responses) + return r.doWithRetries(ctx, responses) } return resp, err } -func (r *dockerBase) retryRequest(ctx context.Context, req *http.Request, responses []*http.Response) (*http.Request, error) { +func (r *request) retryRequest(ctx context.Context, responses []*http.Response) (bool, error) { if len(responses) > 5 { - return nil, nil + return false, nil } last := responses[len(responses)-1] switch last.StatusCode { case http.StatusUnauthorized: log.G(ctx).WithField("header", last.Header.Get("WWW-Authenticate")).Debug("Unauthorized") - if r.auth != nil { - if err := r.auth.AddResponses(ctx, responses); err == nil { - return copyRequest(req) + if r.host.Authorizer != nil { + if err := r.host.Authorizer.AddResponses(ctx, responses); err == nil { + return true, nil } else if !errdefs.IsNotImplemented(err) { - return nil, err + return false, err } } - return nil, nil + + return false, nil case http.StatusMethodNotAllowed: // Support registries which have not properly implemented the HEAD method for // manifests endpoint - if req.Method == http.MethodHead && strings.Contains(req.URL.Path, "/manifests/") { - // TODO: copy request? - req.Method = http.MethodGet - return copyRequest(req) + if r.method == http.MethodHead && strings.Contains(r.path, "/manifests/") { + r.method = http.MethodGet + return true, nil } case http.StatusRequestTimeout, http.StatusTooManyRequests: - return copyRequest(req) + return true, nil } // TODO: Handle 50x errors accounting for attempt history - return nil, nil + return false, nil } -func copyRequest(req *http.Request) (*http.Request, error) { - ireq := *req - if ireq.GetBody != nil { - var err error - ireq.Body, err = ireq.GetBody() - if err != nil { - return nil, err +func (r *request) String() string { + return r.host.Scheme + "://" + r.host.Host + r.path +} + +func requestFields(req *http.Request) logrus.Fields { + fields := map[string]interface{}{ + "request.method": req.Method, + } + for k, vals := range req.Header { + k = strings.ToLower(k) + if k == "authorization" { + continue + } + for i, v := range vals { + field := "request.header." + k + if i > 0 { + field = fmt.Sprintf("%s.%d", field, i) + } + fields[field] = v } } - return &ireq, nil + + return logrus.Fields(fields) +} + +func responseFields(resp *http.Response) logrus.Fields { + fields := map[string]interface{}{ + "response.status": resp.Status, + } + for k, vals := range resp.Header { + k = strings.ToLower(k) + for i, v := range vals { + field := "response.header." + k + if i > 0 { + field = fmt.Sprintf("%s.%d", field, i) + } + fields[field] = v + } + } + + return logrus.Fields(fields) } diff --git a/remotes/docker/resolver_test.go b/remotes/docker/resolver_test.go index 9f9b99ae5..38c9d6616 100644 --- a/remotes/docker/resolver_test.go +++ b/remotes/docker/resolver_test.go @@ -41,9 +41,7 @@ func TestHTTPResolver(t *testing.T) { s := func(h http.Handler) (string, ResolverOptions, func()) { s := httptest.NewServer(h) - options := ResolverOptions{ - PlainHTTP: true, - } + options := ResolverOptions{} base := s.URL[7:] // strip "http://" return base, options, s.Close } @@ -69,9 +67,12 @@ func TestBasicResolver(t *testing.T) { }) base, options, close := tlsServer(wrapped) - options.Authorizer = NewAuthorizer(options.Client, func(string) (string, string, error) { - return "user1", "password1", nil - }) + options.Hosts = ConfigureDefaultRegistries( + WithClient(options.Client), + WithAuthorizer(NewAuthorizer(options.Client, func(string) (string, string, error) { + return "user1", "password1", nil + })), + ) return base, options, close } runBasicTest(t, "testname", basicAuth) @@ -215,7 +216,13 @@ func withTokenServer(th http.Handler, creds func(string) (string, string, error) }) base, options, close := tlsServer(wrapped) - options.Authorizer = NewAuthorizer(options.Client, creds) + options.Hosts = ConfigureDefaultRegistries( + WithClient(options.Client), + WithAuthorizer(NewDockerAuthorizer( + WithAuthClient(options.Client), + WithAuthCreds(creds), + )), + ) options.Client.Transport.(*http.Transport).TLSClientConfig.RootCAs.AddCert(cert) return base, options, func() { s.Close() @@ -232,15 +239,18 @@ func tlsServer(h http.Handler) (string, ResolverOptions, func()) { cert, _ := x509.ParseCertificate(s.TLS.Certificates[0].Certificate[0]) capool.AddCert(cert) - options := ResolverOptions{ - Client: &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{ - RootCAs: capool, - }, + client := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + RootCAs: capool, }, }, } + options := ResolverOptions{ + Hosts: ConfigureDefaultRegistries(WithClient(client)), + // Set deprecated field for tests to use for configuration + Client: client, + } base := s.URL[8:] // strip "https://" return base, options, s.Close }