mirror of
https://github.com/moby/moby.git
synced 2022-11-09 12:21:53 -05:00
Merge pull request #28235 from dmcgowan/fix-registry-authorization-errors
Fix registry authorization errors
This commit is contained in:
commit
48a0c3e831
19 changed files with 374 additions and 53 deletions
|
@ -20,6 +20,7 @@ import (
|
||||||
"github.com/Sirupsen/logrus"
|
"github.com/Sirupsen/logrus"
|
||||||
"github.com/docker/distribution/digest"
|
"github.com/docker/distribution/digest"
|
||||||
"github.com/docker/distribution/registry/client/auth"
|
"github.com/docker/distribution/registry/client/auth"
|
||||||
|
"github.com/docker/distribution/registry/client/auth/challenge"
|
||||||
"github.com/docker/distribution/registry/client/transport"
|
"github.com/docker/distribution/registry/client/transport"
|
||||||
"github.com/docker/docker/api/types"
|
"github.com/docker/docker/api/types"
|
||||||
registrytypes "github.com/docker/docker/api/types/registry"
|
registrytypes "github.com/docker/docker/api/types/registry"
|
||||||
|
@ -291,7 +292,7 @@ func trustedPull(ctx context.Context, cli *command.DockerCli, repoInfo *registry
|
||||||
}
|
}
|
||||||
fmt.Fprintf(cli.Out(), "Pull (%d of %d): %s%s@%s\n", i+1, len(refs), repoInfo.Name(), displayTag, r.digest)
|
fmt.Fprintf(cli.Out(), "Pull (%d of %d): %s%s@%s\n", i+1, len(refs), repoInfo.Name(), displayTag, r.digest)
|
||||||
|
|
||||||
ref, err := reference.WithDigest(repoInfo, r.digest)
|
ref, err := reference.WithDigest(reference.TrimNamed(repoInfo), r.digest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -305,7 +306,7 @@ func trustedPull(ctx context.Context, cli *command.DockerCli, repoInfo *registry
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
trustedRef, err := reference.WithDigest(repoInfo, r.digest)
|
trustedRef, err := reference.WithDigest(reference.TrimNamed(repoInfo), r.digest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -434,7 +435,7 @@ func GetNotaryRepository(streams command.Streams, repoInfo *registry.RepositoryI
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
challengeManager := auth.NewSimpleChallengeManager()
|
challengeManager := challenge.NewSimpleManager()
|
||||||
|
|
||||||
resp, err := pingClient.Do(req)
|
resp, err := pingClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -523,7 +524,7 @@ func TrustedReference(ctx context.Context, cli *command.DockerCli, ref reference
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return reference.WithDigest(ref, r.digest)
|
return reference.WithDigest(reference.TrimNamed(ref), r.digest)
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertTarget(t client.Target) (target, error) {
|
func convertTarget(t client.Target) (target, error) {
|
||||||
|
|
|
@ -33,7 +33,7 @@ func (daemon *Daemon) PullImage(ctx context.Context, image, tag string, metaHead
|
||||||
var dgst digest.Digest
|
var dgst digest.Digest
|
||||||
dgst, err = digest.ParseDigest(tag)
|
dgst, err = digest.ParseDigest(tag)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
ref, err = reference.WithDigest(ref, dgst)
|
ref, err = reference.WithDigest(reference.TrimNamed(ref), dgst)
|
||||||
} else {
|
} else {
|
||||||
ref, err = reference.WithTag(ref, tag)
|
ref, err = reference.WithTag(ref, tag)
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,11 +5,14 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/Sirupsen/logrus"
|
||||||
"github.com/docker/distribution/registry/api/errcode"
|
"github.com/docker/distribution/registry/api/errcode"
|
||||||
"github.com/docker/distribution/registry/api/v2"
|
"github.com/docker/distribution/registry/api/v2"
|
||||||
"github.com/docker/distribution/registry/client"
|
"github.com/docker/distribution/registry/client"
|
||||||
"github.com/docker/distribution/registry/client/auth"
|
"github.com/docker/distribution/registry/client/auth"
|
||||||
"github.com/docker/docker/distribution/xfer"
|
"github.com/docker/docker/distribution/xfer"
|
||||||
|
"github.com/docker/docker/reference"
|
||||||
|
"github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrNoSupport is an error type used for errors indicating that an operation
|
// ErrNoSupport is an error type used for errors indicating that an operation
|
||||||
|
@ -56,6 +59,37 @@ func shouldV2Fallback(err errcode.Error) bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func translatePullError(err error, ref reference.Named) error {
|
||||||
|
switch v := err.(type) {
|
||||||
|
case errcode.Errors:
|
||||||
|
if len(v) != 0 {
|
||||||
|
for _, extra := range v[1:] {
|
||||||
|
logrus.Infof("Ignoring extra error returned from registry: %v", extra)
|
||||||
|
}
|
||||||
|
return translatePullError(v[0], ref)
|
||||||
|
}
|
||||||
|
case errcode.Error:
|
||||||
|
var newErr error
|
||||||
|
switch v.Code {
|
||||||
|
case errcode.ErrorCodeDenied:
|
||||||
|
// ErrorCodeDenied is used when access to the repository was denied
|
||||||
|
newErr = errors.Errorf("repository %s not found: does not exist or no read access", ref.Name())
|
||||||
|
case v2.ErrorCodeManifestUnknown:
|
||||||
|
newErr = errors.Errorf("manifest for %s not found", ref.String())
|
||||||
|
case v2.ErrorCodeNameUnknown:
|
||||||
|
newErr = errors.Errorf("repository %s not found", ref.Name())
|
||||||
|
}
|
||||||
|
if newErr != nil {
|
||||||
|
logrus.Infof("Translating %q to %q", err, newErr)
|
||||||
|
return newErr
|
||||||
|
}
|
||||||
|
case xfer.DoNotRetry:
|
||||||
|
return translatePullError(v.Err, ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// continueOnError returns true if we should fallback to the next endpoint
|
// continueOnError returns true if we should fallback to the next endpoint
|
||||||
// as a result of this error.
|
// as a result of this error.
|
||||||
func continueOnError(err error) bool {
|
func continueOnError(err error) bool {
|
||||||
|
|
|
@ -168,7 +168,7 @@ func Pull(ctx context.Context, ref reference.Named, imagePullConfig *ImagePullCo
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
logrus.Errorf("Not continuing with pull after error: %v", err)
|
logrus.Errorf("Not continuing with pull after error: %v", err)
|
||||||
return err
|
return translatePullError(err, ref)
|
||||||
}
|
}
|
||||||
|
|
||||||
imagePullConfig.ImageEventLogger(ref.String(), repoInfo.Name(), "pull")
|
imagePullConfig.ImageEventLogger(ref.String(), repoInfo.Name(), "pull")
|
||||||
|
@ -179,7 +179,7 @@ func Pull(ctx context.Context, ref reference.Named, imagePullConfig *ImagePullCo
|
||||||
lastErr = fmt.Errorf("no endpoints found for %s", ref.String())
|
lastErr = fmt.Errorf("no endpoints found for %s", ref.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
return lastErr
|
return translatePullError(lastErr, ref)
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeStatus writes a status message to out. If layersDownloaded is true, the
|
// writeStatus writes a status message to out. If layersDownloaded is true, the
|
||||||
|
@ -206,7 +206,7 @@ func ValidateRepoName(name string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func addDigestReference(store reference.Store, ref reference.Named, dgst digest.Digest, id digest.Digest) error {
|
func addDigestReference(store reference.Store, ref reference.Named, dgst digest.Digest, id digest.Digest) error {
|
||||||
dgstRef, err := reference.WithDigest(ref, dgst)
|
dgstRef, err := reference.WithDigest(reference.TrimNamed(ref), dgst)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
@ -671,7 +671,7 @@ func (p *v2Puller) pullManifestList(ctx context.Context, ref reference.Named, mf
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
manifestRef, err := reference.WithDigest(ref, manifestDigest)
|
manifestRef, err := reference.WithDigest(reference.TrimNamed(ref), manifestDigest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
|
|
|
@ -331,7 +331,7 @@ func (pd *v2PushDescriptor) Upload(ctx context.Context, progressOutput progress.
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
canonicalRef, err := distreference.WithDigest(remoteRef, mountCandidate.Digest)
|
canonicalRef, err := distreference.WithDigest(distreference.TrimNamed(remoteRef), mountCandidate.Digest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Errorf("failed to make canonical reference: %v", err)
|
logrus.Errorf("failed to make canonical reference: %v", err)
|
||||||
continue
|
continue
|
||||||
|
|
|
@ -114,7 +114,7 @@ func testPullByDigestNoFallback(c *check.C) {
|
||||||
imageReference := fmt.Sprintf("%s@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", repoName)
|
imageReference := fmt.Sprintf("%s@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", repoName)
|
||||||
out, _, err := dockerCmdWithError("pull", imageReference)
|
out, _, err := dockerCmdWithError("pull", imageReference)
|
||||||
c.Assert(err, checker.NotNil, check.Commentf("expected non-zero exit status and correct error message when pulling non-existing image"))
|
c.Assert(err, checker.NotNil, check.Commentf("expected non-zero exit status and correct error message when pulling non-existing image"))
|
||||||
c.Assert(out, checker.Contains, "manifest unknown", check.Commentf("expected non-zero exit status and correct error message when pulling non-existing image"))
|
c.Assert(out, checker.Contains, fmt.Sprintf("manifest for %s not found", imageReference), check.Commentf("expected non-zero exit status and correct error message when pulling non-existing image"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *DockerRegistrySuite) TestPullByDigestNoFallback(c *check.C) {
|
func (s *DockerRegistrySuite) TestPullByDigestNoFallback(c *check.C) {
|
||||||
|
|
|
@ -48,12 +48,12 @@ func (s *DockerHubPullSuite) TestPullNonExistingImage(c *check.C) {
|
||||||
}
|
}
|
||||||
|
|
||||||
entries := []entry{
|
entries := []entry{
|
||||||
{"library/asdfasdf", "asdfasdf", "foobar"},
|
{"asdfasdf", "asdfasdf", "foobar"},
|
||||||
{"library/asdfasdf", "library/asdfasdf", "foobar"},
|
{"asdfasdf", "library/asdfasdf", "foobar"},
|
||||||
{"library/asdfasdf", "asdfasdf", ""},
|
{"asdfasdf", "asdfasdf", ""},
|
||||||
{"library/asdfasdf", "asdfasdf", "latest"},
|
{"asdfasdf", "asdfasdf", "latest"},
|
||||||
{"library/asdfasdf", "library/asdfasdf", ""},
|
{"asdfasdf", "library/asdfasdf", ""},
|
||||||
{"library/asdfasdf", "library/asdfasdf", "latest"},
|
{"asdfasdf", "library/asdfasdf", "latest"},
|
||||||
}
|
}
|
||||||
|
|
||||||
// The option field indicates "-a" or not.
|
// The option field indicates "-a" or not.
|
||||||
|
@ -98,18 +98,11 @@ func (s *DockerHubPullSuite) TestPullNonExistingImage(c *check.C) {
|
||||||
for record := range recordChan {
|
for record := range recordChan {
|
||||||
if len(record.option) == 0 {
|
if len(record.option) == 0 {
|
||||||
c.Assert(record.err, checker.NotNil, check.Commentf("expected non-zero exit status when pulling non-existing image: %s", record.out))
|
c.Assert(record.err, checker.NotNil, check.Commentf("expected non-zero exit status when pulling non-existing image: %s", record.out))
|
||||||
// Hub returns 401 rather than 404 for nonexistent repos over
|
c.Assert(record.out, checker.Contains, fmt.Sprintf("repository %s not found: does not exist or no read access", record.e.repo), check.Commentf("expected image not found error messages"))
|
||||||
// the v2 protocol - but we should end up falling back to v1,
|
|
||||||
// which does return a 404.
|
|
||||||
tag := record.e.tag
|
|
||||||
if tag == "" {
|
|
||||||
tag = "latest"
|
|
||||||
}
|
|
||||||
c.Assert(record.out, checker.Contains, fmt.Sprintf("Error: image %s:%s not found", record.e.repo, tag), check.Commentf("expected image not found error messages"))
|
|
||||||
} else {
|
} else {
|
||||||
// pull -a on a nonexistent registry should fall back as well
|
// pull -a on a nonexistent registry should fall back as well
|
||||||
c.Assert(record.err, checker.NotNil, check.Commentf("expected non-zero exit status when pulling non-existing image: %s", record.out))
|
c.Assert(record.err, checker.NotNil, check.Commentf("expected non-zero exit status when pulling non-existing image: %s", record.out))
|
||||||
c.Assert(record.out, checker.Contains, fmt.Sprintf("Error: image %s not found", record.e.repo), check.Commentf("expected image not found error messages"))
|
c.Assert(record.out, checker.Contains, fmt.Sprintf("repository %s not found", record.e.repo), check.Commentf("expected image not found error messages"))
|
||||||
c.Assert(record.out, checker.Not(checker.Contains), "unauthorized", check.Commentf(`message should not contain "unauthorized"`))
|
c.Assert(record.out, checker.Not(checker.Contains), "unauthorized", check.Commentf(`message should not contain "unauthorized"`))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -331,7 +331,7 @@ func migrateRefs(root, driverName string, rs refAdder, mappings map[string]image
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if dgst, err := digest.ParseDigest(tag); err == nil {
|
if dgst, err := digest.ParseDigest(tag); err == nil {
|
||||||
canonical, err := reference.WithDigest(ref, dgst)
|
canonical, err := reference.WithDigest(reference.TrimNamed(ref), dgst)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Errorf("migrate tags: invalid digest %q, %q", dgst, err)
|
logrus.Errorf("migrate tags: invalid digest %q, %q", dgst, err)
|
||||||
continue
|
continue
|
||||||
|
|
|
@ -70,6 +70,11 @@ func ParseNamed(s string) (Named, error) {
|
||||||
return r, nil
|
return r, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TrimNamed removes any tag or digest from the named reference
|
||||||
|
func TrimNamed(ref Named) Named {
|
||||||
|
return &namedRef{distreference.TrimNamed(ref)}
|
||||||
|
}
|
||||||
|
|
||||||
// WithName returns a named object representing the given string. If the input
|
// WithName returns a named object representing the given string. If the input
|
||||||
// is invalid ErrReferenceInvalidFormat will be returned.
|
// is invalid ErrReferenceInvalidFormat will be returned.
|
||||||
func WithName(name string) (Named, error) {
|
func WithName(name string) (Named, error) {
|
||||||
|
|
|
@ -10,6 +10,7 @@ import (
|
||||||
|
|
||||||
"github.com/Sirupsen/logrus"
|
"github.com/Sirupsen/logrus"
|
||||||
"github.com/docker/distribution/registry/client/auth"
|
"github.com/docker/distribution/registry/client/auth"
|
||||||
|
"github.com/docker/distribution/registry/client/auth/challenge"
|
||||||
"github.com/docker/distribution/registry/client/transport"
|
"github.com/docker/distribution/registry/client/transport"
|
||||||
"github.com/docker/docker/api/types"
|
"github.com/docker/docker/api/types"
|
||||||
registrytypes "github.com/docker/docker/api/types/registry"
|
registrytypes "github.com/docker/docker/api/types/registry"
|
||||||
|
@ -255,7 +256,7 @@ func (err PingResponseError) Error() string {
|
||||||
// challenge manager for the supported authentication types and
|
// challenge manager for the supported authentication types and
|
||||||
// whether v2 was confirmed by the response. If a response is received but
|
// whether v2 was confirmed by the response. If a response is received but
|
||||||
// cannot be interpreted a PingResponseError will be returned.
|
// cannot be interpreted a PingResponseError will be returned.
|
||||||
func PingV2Registry(endpoint *url.URL, transport http.RoundTripper) (auth.ChallengeManager, bool, error) {
|
func PingV2Registry(endpoint *url.URL, transport http.RoundTripper) (challenge.Manager, bool, error) {
|
||||||
var (
|
var (
|
||||||
foundV2 = false
|
foundV2 = false
|
||||||
v2Version = auth.APIVersion{
|
v2Version = auth.APIVersion{
|
||||||
|
@ -291,7 +292,7 @@ func PingV2Registry(endpoint *url.URL, transport http.RoundTripper) (auth.Challe
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
challengeManager := auth.NewSimpleChallengeManager()
|
challengeManager := challenge.NewSimpleManager()
|
||||||
if err := challengeManager.AddResponse(resp); err != nil {
|
if err := challengeManager.AddResponse(resp); err != nil {
|
||||||
return nil, foundV2, PingResponseError{
|
return nil, foundV2, PingResponseError{
|
||||||
Err: err,
|
Err: err,
|
||||||
|
|
|
@ -44,7 +44,7 @@ github.com/boltdb/bolt fff57c100f4dea1905678da7e90d92429dff2904
|
||||||
github.com/miekg/dns 75e6e86cc601825c5dbcd4e0c209eab180997cd7
|
github.com/miekg/dns 75e6e86cc601825c5dbcd4e0c209eab180997cd7
|
||||||
|
|
||||||
# get graph and distribution packages
|
# get graph and distribution packages
|
||||||
github.com/docker/distribution c04791f441f98bcf073353d7317db83663cf3ea2
|
github.com/docker/distribution 8016d2d8903e378edacac11e4d809efbc987ad61
|
||||||
github.com/vbatts/tar-split v0.10.1
|
github.com/vbatts/tar-split v0.10.1
|
||||||
|
|
||||||
# get go-zfs packages
|
# get go-zfs packages
|
||||||
|
|
30
vendor/github.com/docker/distribution/reference/reference.go
generated
vendored
30
vendor/github.com/docker/distribution/reference/reference.go
generated
vendored
|
@ -24,6 +24,7 @@ package reference
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/docker/distribution/digest"
|
"github.com/docker/distribution/digest"
|
||||||
|
@ -218,6 +219,13 @@ func WithTag(name Named, tag string) (NamedTagged, error) {
|
||||||
if !anchoredTagRegexp.MatchString(tag) {
|
if !anchoredTagRegexp.MatchString(tag) {
|
||||||
return nil, ErrTagInvalidFormat
|
return nil, ErrTagInvalidFormat
|
||||||
}
|
}
|
||||||
|
if canonical, ok := name.(Canonical); ok {
|
||||||
|
return reference{
|
||||||
|
name: name.Name(),
|
||||||
|
tag: tag,
|
||||||
|
digest: canonical.Digest(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
return taggedReference{
|
return taggedReference{
|
||||||
name: name.Name(),
|
name: name.Name(),
|
||||||
tag: tag,
|
tag: tag,
|
||||||
|
@ -230,12 +238,34 @@ func WithDigest(name Named, digest digest.Digest) (Canonical, error) {
|
||||||
if !anchoredDigestRegexp.MatchString(digest.String()) {
|
if !anchoredDigestRegexp.MatchString(digest.String()) {
|
||||||
return nil, ErrDigestInvalidFormat
|
return nil, ErrDigestInvalidFormat
|
||||||
}
|
}
|
||||||
|
if tagged, ok := name.(Tagged); ok {
|
||||||
|
return reference{
|
||||||
|
name: name.Name(),
|
||||||
|
tag: tagged.Tag(),
|
||||||
|
digest: digest,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
return canonicalReference{
|
return canonicalReference{
|
||||||
name: name.Name(),
|
name: name.Name(),
|
||||||
digest: digest,
|
digest: digest,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Match reports whether ref matches the specified pattern.
|
||||||
|
// See https://godoc.org/path#Match for supported patterns.
|
||||||
|
func Match(pattern string, ref Reference) (bool, error) {
|
||||||
|
matched, err := path.Match(pattern, ref.String())
|
||||||
|
if namedRef, isNamed := ref.(Named); isNamed && !matched {
|
||||||
|
matched, _ = path.Match(pattern, namedRef.Name())
|
||||||
|
}
|
||||||
|
return matched, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrimNamed removes any tag or digest from the named reference.
|
||||||
|
func TrimNamed(ref Named) Named {
|
||||||
|
return repository(ref.Name())
|
||||||
|
}
|
||||||
|
|
||||||
func getBestReferenceType(ref reference) Reference {
|
func getBestReferenceType(ref reference) Reference {
|
||||||
if ref.name == "" {
|
if ref.name == "" {
|
||||||
// Allow digest only references
|
// Allow digest only references
|
||||||
|
|
161
vendor/github.com/docker/distribution/registry/api/v2/headerparser.go
generated
vendored
Normal file
161
vendor/github.com/docker/distribution/registry/api/v2/headerparser.go
generated
vendored
Normal file
|
@ -0,0 +1,161 @@
|
||||||
|
package v2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// according to rfc7230
|
||||||
|
reToken = regexp.MustCompile(`^[^"(),/:;<=>?@[\]{}[:space:][:cntrl:]]+`)
|
||||||
|
reQuotedValue = regexp.MustCompile(`^[^\\"]+`)
|
||||||
|
reEscapedCharacter = regexp.MustCompile(`^[[:blank:][:graph:]]`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// parseForwardedHeader is a benevolent parser of Forwarded header defined in rfc7239. The header contains
|
||||||
|
// a comma-separated list of forwarding key-value pairs. Each list element is set by single proxy. The
|
||||||
|
// function parses only the first element of the list, which is set by the very first proxy. It returns a map
|
||||||
|
// of corresponding key-value pairs and an unparsed slice of the input string.
|
||||||
|
//
|
||||||
|
// Examples of Forwarded header values:
|
||||||
|
//
|
||||||
|
// 1. Forwarded: For=192.0.2.43; Proto=https,For="[2001:db8:cafe::17]",For=unknown
|
||||||
|
// 2. Forwarded: for="192.0.2.43:443"; host="registry.example.org", for="10.10.05.40:80"
|
||||||
|
//
|
||||||
|
// The first will be parsed into {"for": "192.0.2.43", "proto": "https"} while the second into
|
||||||
|
// {"for": "192.0.2.43:443", "host": "registry.example.org"}.
|
||||||
|
func parseForwardedHeader(forwarded string) (map[string]string, string, error) {
|
||||||
|
// Following are states of forwarded header parser. Any state could transition to a failure.
|
||||||
|
const (
|
||||||
|
// terminating state; can transition to Parameter
|
||||||
|
stateElement = iota
|
||||||
|
// terminating state; can transition to KeyValueDelimiter
|
||||||
|
stateParameter
|
||||||
|
// can transition to Value
|
||||||
|
stateKeyValueDelimiter
|
||||||
|
// can transition to one of { QuotedValue, PairEnd }
|
||||||
|
stateValue
|
||||||
|
// can transition to one of { EscapedCharacter, PairEnd }
|
||||||
|
stateQuotedValue
|
||||||
|
// can transition to one of { QuotedValue }
|
||||||
|
stateEscapedCharacter
|
||||||
|
// terminating state; can transition to one of { Parameter, Element }
|
||||||
|
statePairEnd
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
parameter string
|
||||||
|
value string
|
||||||
|
parse = forwarded[:]
|
||||||
|
res = map[string]string{}
|
||||||
|
state = stateElement
|
||||||
|
)
|
||||||
|
|
||||||
|
Loop:
|
||||||
|
for {
|
||||||
|
// skip spaces unless in quoted value
|
||||||
|
if state != stateQuotedValue && state != stateEscapedCharacter {
|
||||||
|
parse = strings.TrimLeftFunc(parse, unicode.IsSpace)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(parse) == 0 {
|
||||||
|
if state != stateElement && state != statePairEnd && state != stateParameter {
|
||||||
|
return nil, parse, fmt.Errorf("unexpected end of input")
|
||||||
|
}
|
||||||
|
// terminating
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
switch state {
|
||||||
|
// terminate at list element delimiter
|
||||||
|
case stateElement:
|
||||||
|
if parse[0] == ',' {
|
||||||
|
parse = parse[1:]
|
||||||
|
break Loop
|
||||||
|
}
|
||||||
|
state = stateParameter
|
||||||
|
|
||||||
|
// parse parameter (the key of key-value pair)
|
||||||
|
case stateParameter:
|
||||||
|
match := reToken.FindString(parse)
|
||||||
|
if len(match) == 0 {
|
||||||
|
return nil, parse, fmt.Errorf("failed to parse token at position %d", len(forwarded)-len(parse))
|
||||||
|
}
|
||||||
|
parameter = strings.ToLower(match)
|
||||||
|
parse = parse[len(match):]
|
||||||
|
state = stateKeyValueDelimiter
|
||||||
|
|
||||||
|
// parse '='
|
||||||
|
case stateKeyValueDelimiter:
|
||||||
|
if parse[0] != '=' {
|
||||||
|
return nil, parse, fmt.Errorf("expected '=', not '%c' at position %d", parse[0], len(forwarded)-len(parse))
|
||||||
|
}
|
||||||
|
parse = parse[1:]
|
||||||
|
state = stateValue
|
||||||
|
|
||||||
|
// parse value or quoted value
|
||||||
|
case stateValue:
|
||||||
|
if parse[0] == '"' {
|
||||||
|
parse = parse[1:]
|
||||||
|
state = stateQuotedValue
|
||||||
|
} else {
|
||||||
|
value = reToken.FindString(parse)
|
||||||
|
if len(value) == 0 {
|
||||||
|
return nil, parse, fmt.Errorf("failed to parse value at position %d", len(forwarded)-len(parse))
|
||||||
|
}
|
||||||
|
if _, exists := res[parameter]; exists {
|
||||||
|
return nil, parse, fmt.Errorf("duplicate parameter %q at position %d", parameter, len(forwarded)-len(parse))
|
||||||
|
}
|
||||||
|
res[parameter] = value
|
||||||
|
parse = parse[len(value):]
|
||||||
|
value = ""
|
||||||
|
state = statePairEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
// parse a part of quoted value until the first backslash
|
||||||
|
case stateQuotedValue:
|
||||||
|
match := reQuotedValue.FindString(parse)
|
||||||
|
value += match
|
||||||
|
parse = parse[len(match):]
|
||||||
|
switch {
|
||||||
|
case len(parse) == 0:
|
||||||
|
return nil, parse, fmt.Errorf("unterminated quoted string")
|
||||||
|
case parse[0] == '"':
|
||||||
|
res[parameter] = value
|
||||||
|
value = ""
|
||||||
|
parse = parse[1:]
|
||||||
|
state = statePairEnd
|
||||||
|
case parse[0] == '\\':
|
||||||
|
parse = parse[1:]
|
||||||
|
state = stateEscapedCharacter
|
||||||
|
}
|
||||||
|
|
||||||
|
// parse escaped character in a quoted string, ignore the backslash
|
||||||
|
// transition back to QuotedValue state
|
||||||
|
case stateEscapedCharacter:
|
||||||
|
c := reEscapedCharacter.FindString(parse)
|
||||||
|
if len(c) == 0 {
|
||||||
|
return nil, parse, fmt.Errorf("invalid escape sequence at position %d", len(forwarded)-len(parse)-1)
|
||||||
|
}
|
||||||
|
value += c
|
||||||
|
parse = parse[1:]
|
||||||
|
state = stateQuotedValue
|
||||||
|
|
||||||
|
// expect either a new key-value pair, new list or end of input
|
||||||
|
case statePairEnd:
|
||||||
|
switch parse[0] {
|
||||||
|
case ';':
|
||||||
|
parse = parse[1:]
|
||||||
|
state = stateParameter
|
||||||
|
case ',':
|
||||||
|
state = stateElement
|
||||||
|
default:
|
||||||
|
return nil, parse, fmt.Errorf("expected ',' or ';', not %c at position %d", parse[0], len(forwarded)-len(parse))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res, parse, nil
|
||||||
|
}
|
67
vendor/github.com/docker/distribution/registry/api/v2/urls.go
generated
vendored
67
vendor/github.com/docker/distribution/registry/api/v2/urls.go
generated
vendored
|
@ -1,8 +1,10 @@
|
||||||
package v2
|
package v2
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/docker/distribution/reference"
|
"github.com/docker/distribution/reference"
|
||||||
|
@ -49,10 +51,14 @@ func NewURLBuilderFromRequest(r *http.Request, relative bool) *URLBuilder {
|
||||||
var scheme string
|
var scheme string
|
||||||
|
|
||||||
forwardedProto := r.Header.Get("X-Forwarded-Proto")
|
forwardedProto := r.Header.Get("X-Forwarded-Proto")
|
||||||
|
// TODO: log the error
|
||||||
|
forwardedHeader, _, _ := parseForwardedHeader(r.Header.Get("Forwarded"))
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case len(forwardedProto) > 0:
|
case len(forwardedProto) > 0:
|
||||||
scheme = forwardedProto
|
scheme = forwardedProto
|
||||||
|
case len(forwardedHeader["proto"]) > 0:
|
||||||
|
scheme = forwardedHeader["proto"]
|
||||||
case r.TLS != nil:
|
case r.TLS != nil:
|
||||||
scheme = "https"
|
scheme = "https"
|
||||||
case len(r.URL.Scheme) > 0:
|
case len(r.URL.Scheme) > 0:
|
||||||
|
@ -62,14 +68,46 @@ func NewURLBuilderFromRequest(r *http.Request, relative bool) *URLBuilder {
|
||||||
}
|
}
|
||||||
|
|
||||||
host := r.Host
|
host := r.Host
|
||||||
forwardedHost := r.Header.Get("X-Forwarded-Host")
|
|
||||||
if len(forwardedHost) > 0 {
|
if forwardedHost := r.Header.Get("X-Forwarded-Host"); len(forwardedHost) > 0 {
|
||||||
// According to the Apache mod_proxy docs, X-Forwarded-Host can be a
|
// According to the Apache mod_proxy docs, X-Forwarded-Host can be a
|
||||||
// comma-separated list of hosts, to which each proxy appends the
|
// comma-separated list of hosts, to which each proxy appends the
|
||||||
// requested host. We want to grab the first from this comma-separated
|
// requested host. We want to grab the first from this comma-separated
|
||||||
// list.
|
// list.
|
||||||
hosts := strings.SplitN(forwardedHost, ",", 2)
|
hosts := strings.SplitN(forwardedHost, ",", 2)
|
||||||
host = strings.TrimSpace(hosts[0])
|
host = strings.TrimSpace(hosts[0])
|
||||||
|
} else if addr, exists := forwardedHeader["for"]; exists {
|
||||||
|
host = addr
|
||||||
|
} else if h, exists := forwardedHeader["host"]; exists {
|
||||||
|
host = h
|
||||||
|
}
|
||||||
|
|
||||||
|
portLessHost, port := host, ""
|
||||||
|
if !isIPv6Address(portLessHost) {
|
||||||
|
// with go 1.6, this would treat the last part of IPv6 address as a port
|
||||||
|
portLessHost, port, _ = net.SplitHostPort(host)
|
||||||
|
}
|
||||||
|
if forwardedPort := r.Header.Get("X-Forwarded-Port"); len(port) == 0 && len(forwardedPort) > 0 {
|
||||||
|
ports := strings.SplitN(forwardedPort, ",", 2)
|
||||||
|
forwardedPort = strings.TrimSpace(ports[0])
|
||||||
|
if _, err := strconv.ParseInt(forwardedPort, 10, 32); err == nil {
|
||||||
|
port = forwardedPort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(portLessHost) > 0 {
|
||||||
|
host = portLessHost
|
||||||
|
}
|
||||||
|
if len(port) > 0 {
|
||||||
|
// remove enclosing brackets of ipv6 address otherwise they will be duplicated
|
||||||
|
if len(host) > 1 && host[0] == '[' && host[len(host)-1] == ']' {
|
||||||
|
host = host[1 : len(host)-1]
|
||||||
|
}
|
||||||
|
// JoinHostPort properly encloses ipv6 addresses in square brackets
|
||||||
|
host = net.JoinHostPort(host, port)
|
||||||
|
} else if isIPv6Address(host) && host[0] != '[' {
|
||||||
|
// ipv6 needs to be enclosed in square brackets in urls
|
||||||
|
host = "[" + host + "]"
|
||||||
}
|
}
|
||||||
|
|
||||||
basePath := routeDescriptorsMap[RouteNameBase].Path
|
basePath := routeDescriptorsMap[RouteNameBase].Path
|
||||||
|
@ -249,3 +287,28 @@ func appendValues(u string, values ...url.Values) string {
|
||||||
|
|
||||||
return appendValuesURL(up, values...).String()
|
return appendValuesURL(up, values...).String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isIPv6Address returns true if given string is a valid IPv6 address. No port is allowed. The address may be
|
||||||
|
// enclosed in square brackets.
|
||||||
|
func isIPv6Address(host string) bool {
|
||||||
|
if len(host) > 1 && host[0] == '[' && host[len(host)-1] == ']' {
|
||||||
|
host = host[1 : len(host)-1]
|
||||||
|
}
|
||||||
|
// The IPv6 scoped addressing zone identifier starts after the last percent sign.
|
||||||
|
if i := strings.LastIndexByte(host, '%'); i > 0 {
|
||||||
|
host = host[:i]
|
||||||
|
}
|
||||||
|
ip := net.ParseIP(host)
|
||||||
|
if ip == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if ip.To16() == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if ip.To4() == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// dot can be present in ipv4-mapped address, it needs to come after a colon though
|
||||||
|
i := strings.IndexAny(host, ":.")
|
||||||
|
return i >= 0 && host[i] == ':'
|
||||||
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
package auth
|
package challenge
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/url"
|
"net/url"
|
|
@ -1,4 +1,4 @@
|
||||||
package auth
|
package challenge
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
@ -18,12 +18,12 @@ type Challenge struct {
|
||||||
Parameters map[string]string
|
Parameters map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChallengeManager manages the challenges for endpoints.
|
// Manager manages the challenges for endpoints.
|
||||||
// The challenges are pulled out of HTTP responses. Only
|
// The challenges are pulled out of HTTP responses. Only
|
||||||
// responses which expect challenges should be added to
|
// responses which expect challenges should be added to
|
||||||
// the manager, since a non-unauthorized request will be
|
// the manager, since a non-unauthorized request will be
|
||||||
// viewed as not requiring challenges.
|
// viewed as not requiring challenges.
|
||||||
type ChallengeManager interface {
|
type Manager interface {
|
||||||
// GetChallenges returns the challenges for the given
|
// GetChallenges returns the challenges for the given
|
||||||
// endpoint URL.
|
// endpoint URL.
|
||||||
GetChallenges(endpoint url.URL) ([]Challenge, error)
|
GetChallenges(endpoint url.URL) ([]Challenge, error)
|
||||||
|
@ -37,19 +37,19 @@ type ChallengeManager interface {
|
||||||
AddResponse(resp *http.Response) error
|
AddResponse(resp *http.Response) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSimpleChallengeManager returns an instance of
|
// NewSimpleManager returns an instance of
|
||||||
// ChallengeManger which only maps endpoints to challenges
|
// Manger which only maps endpoints to challenges
|
||||||
// based on the responses which have been added the
|
// based on the responses which have been added the
|
||||||
// manager. The simple manager will make no attempt to
|
// manager. The simple manager will make no attempt to
|
||||||
// perform requests on the endpoints or cache the responses
|
// perform requests on the endpoints or cache the responses
|
||||||
// to a backend.
|
// to a backend.
|
||||||
func NewSimpleChallengeManager() ChallengeManager {
|
func NewSimpleManager() Manager {
|
||||||
return &simpleChallengeManager{
|
return &simpleManager{
|
||||||
Challanges: make(map[string][]Challenge),
|
Challanges: make(map[string][]Challenge),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type simpleChallengeManager struct {
|
type simpleManager struct {
|
||||||
sync.RWMutex
|
sync.RWMutex
|
||||||
Challanges map[string][]Challenge
|
Challanges map[string][]Challenge
|
||||||
}
|
}
|
||||||
|
@ -59,7 +59,7 @@ func normalizeURL(endpoint *url.URL) {
|
||||||
endpoint.Host = canonicalAddr(endpoint)
|
endpoint.Host = canonicalAddr(endpoint)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *simpleChallengeManager) GetChallenges(endpoint url.URL) ([]Challenge, error) {
|
func (m *simpleManager) GetChallenges(endpoint url.URL) ([]Challenge, error) {
|
||||||
normalizeURL(&endpoint)
|
normalizeURL(&endpoint)
|
||||||
|
|
||||||
m.RLock()
|
m.RLock()
|
||||||
|
@ -68,7 +68,7 @@ func (m *simpleChallengeManager) GetChallenges(endpoint url.URL) ([]Challenge, e
|
||||||
return challenges, nil
|
return challenges, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *simpleChallengeManager) AddResponse(resp *http.Response) error {
|
func (m *simpleManager) AddResponse(resp *http.Response) error {
|
||||||
challenges := ResponseChallenges(resp)
|
challenges := ResponseChallenges(resp)
|
||||||
if resp.Request == nil {
|
if resp.Request == nil {
|
||||||
return fmt.Errorf("missing request reference")
|
return fmt.Errorf("missing request reference")
|
11
vendor/github.com/docker/distribution/registry/client/auth/session.go
generated
vendored
11
vendor/github.com/docker/distribution/registry/client/auth/session.go
generated
vendored
|
@ -12,6 +12,7 @@ import (
|
||||||
|
|
||||||
"github.com/Sirupsen/logrus"
|
"github.com/Sirupsen/logrus"
|
||||||
"github.com/docker/distribution/registry/client"
|
"github.com/docker/distribution/registry/client"
|
||||||
|
"github.com/docker/distribution/registry/client/auth/challenge"
|
||||||
"github.com/docker/distribution/registry/client/transport"
|
"github.com/docker/distribution/registry/client/transport"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -58,7 +59,7 @@ type CredentialStore interface {
|
||||||
// schemes. The handlers are tried in order, the higher priority authentication
|
// schemes. The handlers are tried in order, the higher priority authentication
|
||||||
// methods should be first. The challengeMap holds a list of challenges for
|
// methods should be first. The challengeMap holds a list of challenges for
|
||||||
// a given root API endpoint (for example "https://registry-1.docker.io/v2/").
|
// a given root API endpoint (for example "https://registry-1.docker.io/v2/").
|
||||||
func NewAuthorizer(manager ChallengeManager, handlers ...AuthenticationHandler) transport.RequestModifier {
|
func NewAuthorizer(manager challenge.Manager, handlers ...AuthenticationHandler) transport.RequestModifier {
|
||||||
return &endpointAuthorizer{
|
return &endpointAuthorizer{
|
||||||
challenges: manager,
|
challenges: manager,
|
||||||
handlers: handlers,
|
handlers: handlers,
|
||||||
|
@ -66,7 +67,7 @@ func NewAuthorizer(manager ChallengeManager, handlers ...AuthenticationHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
type endpointAuthorizer struct {
|
type endpointAuthorizer struct {
|
||||||
challenges ChallengeManager
|
challenges challenge.Manager
|
||||||
handlers []AuthenticationHandler
|
handlers []AuthenticationHandler
|
||||||
transport http.RoundTripper
|
transport http.RoundTripper
|
||||||
}
|
}
|
||||||
|
@ -94,11 +95,11 @@ func (ea *endpointAuthorizer) ModifyRequest(req *http.Request) error {
|
||||||
|
|
||||||
if len(challenges) > 0 {
|
if len(challenges) > 0 {
|
||||||
for _, handler := range ea.handlers {
|
for _, handler := range ea.handlers {
|
||||||
for _, challenge := range challenges {
|
for _, c := range challenges {
|
||||||
if challenge.Scheme != handler.Scheme() {
|
if c.Scheme != handler.Scheme() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := handler.AuthorizeRequest(req, challenge.Parameters); err != nil {
|
if err := handler.AuthorizeRequest(req, c.Parameters); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
42
vendor/github.com/docker/distribution/registry/client/errors.go
generated
vendored
42
vendor/github.com/docker/distribution/registry/client/errors.go
generated
vendored
|
@ -9,6 +9,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/docker/distribution/registry/api/errcode"
|
"github.com/docker/distribution/registry/api/errcode"
|
||||||
|
"github.com/docker/distribution/registry/client/auth/challenge"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrNoErrorsInBody is returned when an HTTP response body parses to an empty
|
// ErrNoErrorsInBody is returned when an HTTP response body parses to an empty
|
||||||
|
@ -82,21 +83,52 @@ func parseHTTPErrorResponse(statusCode int, r io.Reader) error {
|
||||||
return errors
|
return errors
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func makeErrorList(err error) []error {
|
||||||
|
if errL, ok := err.(errcode.Errors); ok {
|
||||||
|
return []error(errL)
|
||||||
|
}
|
||||||
|
return []error{err}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeErrors(err1, err2 error) error {
|
||||||
|
return errcode.Errors(append(makeErrorList(err1), makeErrorList(err2)...))
|
||||||
|
}
|
||||||
|
|
||||||
// HandleErrorResponse returns error parsed from HTTP response for an
|
// HandleErrorResponse returns error parsed from HTTP response for an
|
||||||
// unsuccessful HTTP response code (in the range 400 - 499 inclusive). An
|
// unsuccessful HTTP response code (in the range 400 - 499 inclusive). An
|
||||||
// UnexpectedHTTPStatusError returned for response code outside of expected
|
// UnexpectedHTTPStatusError returned for response code outside of expected
|
||||||
// range.
|
// range.
|
||||||
func HandleErrorResponse(resp *http.Response) error {
|
func HandleErrorResponse(resp *http.Response) error {
|
||||||
if resp.StatusCode == 401 {
|
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
|
||||||
|
// Check for OAuth errors within the `WWW-Authenticate` header first
|
||||||
|
// See https://tools.ietf.org/html/rfc6750#section-3
|
||||||
|
for _, c := range challenge.ResponseChallenges(resp) {
|
||||||
|
if c.Scheme == "bearer" {
|
||||||
|
var err errcode.Error
|
||||||
|
// codes defined at https://tools.ietf.org/html/rfc6750#section-3.1
|
||||||
|
switch c.Parameters["error"] {
|
||||||
|
case "invalid_token":
|
||||||
|
err.Code = errcode.ErrorCodeUnauthorized
|
||||||
|
case "insufficient_scope":
|
||||||
|
err.Code = errcode.ErrorCodeDenied
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if description := c.Parameters["error_description"]; description != "" {
|
||||||
|
err.Message = description
|
||||||
|
} else {
|
||||||
|
err.Message = err.Code.Message()
|
||||||
|
}
|
||||||
|
|
||||||
|
return mergeErrors(err, parseHTTPErrorResponse(resp.StatusCode, resp.Body))
|
||||||
|
}
|
||||||
|
}
|
||||||
err := parseHTTPErrorResponse(resp.StatusCode, resp.Body)
|
err := parseHTTPErrorResponse(resp.StatusCode, resp.Body)
|
||||||
if uErr, ok := err.(*UnexpectedHTTPResponseError); ok {
|
if uErr, ok := err.(*UnexpectedHTTPResponseError); ok && resp.StatusCode == 401 {
|
||||||
return errcode.ErrorCodeUnauthorized.WithDetail(uErr.Response)
|
return errcode.ErrorCodeUnauthorized.WithDetail(uErr.Response)
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
|
|
||||||
return parseHTTPErrorResponse(resp.StatusCode, resp.Body)
|
|
||||||
}
|
|
||||||
return &UnexpectedHTTPStatusError{Status: resp.Status}
|
return &UnexpectedHTTPStatusError{Status: resp.Status}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue