diff --git a/cmd/dist/common.go b/cmd/dist/common.go index 7c8d41e62..0f3190ae4 100644 --- a/cmd/dist/common.go +++ b/cmd/dist/common.go @@ -1,9 +1,14 @@ package main import ( + "bufio" "context" + "crypto/tls" + "fmt" "net" + "net/http" "path/filepath" + "strings" "time" imagesapi "github.com/containerd/containerd/api/services/images" @@ -12,10 +17,31 @@ import ( "github.com/containerd/containerd/remotes" "github.com/containerd/containerd/remotes/docker" imagesservice "github.com/containerd/containerd/services/images" + "github.com/crosbymichael/console" + "github.com/pkg/errors" "github.com/urfave/cli" "google.golang.org/grpc" ) +var registryFlags = []cli.Flag{ + cli.BoolFlag{ + Name: "skip-verify,k", + Usage: "Skip SSL certificate validation", + }, + cli.BoolFlag{ + Name: "plain-http", + Usage: "Allow connections using plain HTTP", + }, + cli.StringFlag{ + Name: "user,u", + Usage: "user[:password] Registry user and password", + }, + cli.StringFlag{ + Name: "refresh", + Usage: "Refresh token for authorization server", + }, +} + func resolveContentStore(context *cli.Context) (*content.Store, error) { root := filepath.Join(context.GlobalString("root"), "content") if !filepath.IsAbs(root) { @@ -50,6 +76,71 @@ func connectGRPC(context *cli.Context) (*grpc.ClientConn, error) { } // getResolver prepares the resolver from the environment and options. -func getResolver(ctx context.Context) (remotes.Resolver, error) { - return docker.NewResolver(), nil +func getResolver(ctx context.Context, clicontext *cli.Context) (remotes.Resolver, error) { + username := clicontext.String("user") + var secret string + if i := strings.IndexByte(username, ':'); i > 0 { + secret = username[i+1:] + username = username[0:i] + } + options := docker.ResolverOptions{ + PlainHTTP: clicontext.Bool("plain-http"), + } + if username != "" { + if secret == "" { + fmt.Printf("Password: ") + + var err error + secret, err = passwordPrompt() + if err != nil { + return nil, err + } + + fmt.Print("\n") + } + } else if rt := clicontext.String("refresh"); rt != "" { + secret = rt + } + + options.Credentials = func(host string) (string, string, error) { + // Only one host + return username, secret, nil + } + + tr := &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + DualStack: true, + }).DialContext, + MaxIdleConns: 10, + IdleConnTimeout: 30 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: clicontext.Bool("insecure"), + }, + ExpectContinueTimeout: 5 * time.Second, + } + + options.Client = &http.Client{ + Transport: tr, + } + + return docker.NewResolver(options), nil +} + +func passwordPrompt() (string, error) { + c := console.Current() + defer c.Reset() + + if err := c.DisableEcho(); err != nil { + return "", errors.Wrap(err, "failed to disable echo") + } + + line, _, err := bufio.NewReader(c).ReadLine() + if err != nil { + return "", errors.Wrap(err, "failed to read line") + } + return string(line), nil } diff --git a/cmd/dist/fetch.go b/cmd/dist/fetch.go index f319b4bce..4446f7235 100644 --- a/cmd/dist/fetch.go +++ b/cmd/dist/fetch.go @@ -39,7 +39,7 @@ not use this implementation as a guide. The end goal should be having metadata, content and snapshots ready for a direct use via the 'ctr run'. Most of this is experimental and there are few leaps to make this work.`, - Flags: []cli.Flag{}, + Flags: registryFlags, Action: func(clicontext *cli.Context) error { var ( ctx = background @@ -51,7 +51,7 @@ Most of this is experimental and there are few leaps to make this work.`, return err } - resolver, err := getResolver(ctx) + resolver, err := getResolver(ctx, clicontext) if err != nil { return err } diff --git a/cmd/dist/fetchobject.go b/cmd/dist/fetchobject.go index c38d4b63a..69d393e83 100644 --- a/cmd/dist/fetchobject.go +++ b/cmd/dist/fetchobject.go @@ -18,13 +18,13 @@ var fetchObjectCommand = cli.Command{ Usage: "retrieve objects from a remote", ArgsUsage: "[flags] [, ...]", Description: `Fetch objects by identifier from a remote.`, - Flags: []cli.Flag{ + Flags: append([]cli.Flag{ cli.DurationFlag{ Name: "timeout", Usage: "total timeout for fetch", EnvVar: "CONTAINERD_FETCH_TIMEOUT", }, - }, + }, registryFlags...), Action: func(context *cli.Context) error { var ( ctx = background @@ -38,7 +38,7 @@ var fetchObjectCommand = cli.Command{ defer cancel() } - resolver, err := getResolver(ctx) + resolver, err := getResolver(ctx, context) if err != nil { return err } diff --git a/cmd/dist/pull.go b/cmd/dist/pull.go index 84840342c..920bc60c4 100644 --- a/cmd/dist/pull.go +++ b/cmd/dist/pull.go @@ -35,7 +35,7 @@ command. As part of this process, we do the following: 2. Prepare the snapshot filesystem with the pulled resources. 3. Register metadata for the image. `, - Flags: []cli.Flag{}, + Flags: registryFlags, Action: func(clicontext *cli.Context) error { var ( ctx = background @@ -52,7 +52,7 @@ command. As part of this process, we do the following: return err } - resolver, err := getResolver(ctx) + resolver, err := getResolver(ctx, clicontext) if err != nil { return err } @@ -75,6 +75,7 @@ command. As part of this process, we do the following: ongoing.add(ref) name, desc, fetcher, err := resolver.Resolve(ctx, ref) if err != nil { + log.G(ctx).WithError(err).Error("failed to resolve") return err } log.G(ctx).WithField("image", name).Debug("fetching") diff --git a/remotes/docker/auth.go b/remotes/docker/auth.go new file mode 100644 index 000000000..aa33752a6 --- /dev/null +++ b/remotes/docker/auth.go @@ -0,0 +1,182 @@ +package docker + +import ( + "net/http" + "sort" + "strings" +) + +type authenticationScheme byte + +const ( + basicAuth authenticationScheme = 1 << iota // Defined in RFC 7617 + digestAuth // Defined in RFC 7616 + bearerAuth // Defined in RFC 6750 +) + +// challenge carries information from a WWW-Authenticate response header. +// See RFC 2617. +type challenge struct { + // scheme is the auth-scheme according to RFC 2617 + scheme authenticationScheme + + // parameters are the auth-params according to RFC 2617 + parameters map[string]string +} + +type byScheme []challenge + +func (bs byScheme) Len() int { return len(bs) } +func (bs byScheme) Swap(i, j int) { bs[i], bs[j] = bs[j], bs[i] } + +// Sort in priority order: token > digest > basic +func (bs byScheme) Less(i, j int) bool { return bs[i].scheme > bs[j].scheme } + +// Octet types from RFC 2616. +type octetType byte + +var octetTypes [256]octetType + +const ( + isToken octetType = 1 << iota + isSpace +) + +func init() { + // OCTET = + // CHAR = + // CTL = + // CR = + // LF = + // SP = + // HT = + // <"> = + // CRLF = CR LF + // LWS = [CRLF] 1*( SP | HT ) + // TEXT = + // separators = "(" | ")" | "<" | ">" | "@" | "," | ";" | ":" | "\" | <"> + // | "/" | "[" | "]" | "?" | "=" | "{" | "}" | SP | HT + // token = 1* + // qdtext = > + + for c := 0; c < 256; c++ { + var t octetType + isCtl := c <= 31 || c == 127 + isChar := 0 <= c && c <= 127 + isSeparator := strings.IndexRune(" \t\"(),/:;<=>?@[]\\{}", rune(c)) >= 0 + if strings.IndexRune(" \t\r\n", rune(c)) >= 0 { + t |= isSpace + } + if isChar && !isCtl && !isSeparator { + t |= isToken + } + octetTypes[c] = t + } +} + +func parseAuthHeader(header http.Header) []challenge { + challenges := []challenge{} + for _, h := range header[http.CanonicalHeaderKey("WWW-Authenticate")] { + v, p := parseValueAndParams(h) + var s authenticationScheme + switch v { + case "basic": + s = basicAuth + case "digest": + s = digestAuth + case "bearer": + s = bearerAuth + default: + continue + } + challenges = append(challenges, challenge{scheme: s, parameters: p}) + } + sort.Stable(byScheme(challenges)) + return challenges +} + +func parseValueAndParams(header string) (value string, params map[string]string) { + params = make(map[string]string) + value, s := expectToken(header) + if value == "" { + return + } + value = strings.ToLower(value) + for { + var pkey string + pkey, s = expectToken(skipSpace(s)) + if pkey == "" { + return + } + if !strings.HasPrefix(s, "=") { + return + } + var pvalue string + pvalue, s = expectTokenOrQuoted(s[1:]) + if pvalue == "" { + return + } + pkey = strings.ToLower(pkey) + params[pkey] = pvalue + s = skipSpace(s) + if !strings.HasPrefix(s, ",") { + return + } + s = s[1:] + } +} + +func skipSpace(s string) (rest string) { + i := 0 + for ; i < len(s); i++ { + if octetTypes[s[i]]&isSpace == 0 { + break + } + } + return s[i:] +} + +func expectToken(s string) (token, rest string) { + i := 0 + for ; i < len(s); i++ { + if octetTypes[s[i]]&isToken == 0 { + break + } + } + return s[:i], s[i:] +} + +func expectTokenOrQuoted(s string) (value string, rest string) { + if !strings.HasPrefix(s, "\"") { + return expectToken(s) + } + s = s[1:] + for i := 0; i < len(s); i++ { + switch s[i] { + case '"': + return s[:i], s[i+1:] + case '\\': + p := make([]byte, len(s)-1) + j := copy(p, s[:i]) + escape := true + for i = i + 1; i < len(s); i++ { + b := s[i] + switch { + case escape: + escape = false + p[j] = b + j++ + case b == '\\': + escape = true + case b == '"': + return string(p[:j]), s[i+1:] + default: + p[j] = b + j++ + } + } + return "", "" + } + } + return "", "" +} diff --git a/remotes/docker/resolver.go b/remotes/docker/resolver.go index 985d885e1..804ac555c 100644 --- a/remotes/docker/resolver.go +++ b/remotes/docker/resolver.go @@ -7,10 +7,12 @@ import ( "io" "io/ioutil" "net/http" + "net/textproto" "net/url" "path" "strconv" "strings" + "time" "github.com/Sirupsen/logrus" "github.com/containerd/containerd/images" @@ -23,15 +25,43 @@ import ( "golang.org/x/net/context/ctxhttp" ) -// NOTE(stevvooe): Most of the code below this point is prototype code to -// demonstrate a very simplified docker.io fetcher. We have a lot of hard coded -// values but we leave many of the details down to the fetcher, creating a lot -// of room for ways to fetch content. +var ( + // ErrNoToken is returned if a request is successful but the body does not + // contain an authorization token. + ErrNoToken = errors.New("authorization server did not include a token in the response") -type dockerResolver struct{} + // ErrInvalidAuthorization is used when credentials are passed to a server but + // those credentials are rejected. + ErrInvalidAuthorization = errors.New("authorization failed") +) -func NewResolver() remotes.Resolver { - return &dockerResolver{} +type dockerResolver struct { + credentials func(string) (string, string, error) + plainHTTP bool + client *http.Client +} + +// ResolverOptions are used to configured a new Docker register resolver +type ResolverOptions struct { + // Credentials provides username and secret given a host. + // If username is empty but a secret is given, that secret + // is interpretted as a long lived token. + Credentials func(string) (string, string, error) + + // 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 +} + +// NewResolver returns a new resolver to a Docker registry +func NewResolver(options ResolverOptions) remotes.Resolver { + return &dockerResolver{ + credentials: options.Credentials, + plainHTTP: options.PlainHTTP, + client: options.Client, + } } var _ remotes.Resolver = &dockerResolver{} @@ -43,31 +73,38 @@ func (r *dockerResolver) Resolve(ctx context.Context, ref string) (string, ocisp } var ( - base url.URL - token string + base url.URL + username, secret string ) - switch refspec.Hostname() { - case "docker.io": - base.Scheme = "https" + host := refspec.Hostname() + base.Scheme = "https" + + if host == "docker.io" { base.Host = "registry-1.docker.io" - prefix := strings.TrimPrefix(refspec.Locator, "docker.io/") - base.Path = path.Join("/v2", prefix) - token, err = getToken(ctx, "repository:"+prefix+":pull") + } else { + base.Host = host + + if r.plainHTTP || strings.HasPrefix(host, "localhost:") { + base.Scheme = "http" + } + } + + if r.credentials != nil { + username, secret, err = r.credentials(base.Host) if err != nil { return "", ocispec.Descriptor{}, nil, err } - case "localhost:5000": - base.Scheme = "http" - base.Host = "localhost:5000" - base.Path = path.Join("/v2", strings.TrimPrefix(refspec.Locator, "localhost:5000/")) - default: - return "", ocispec.Descriptor{}, nil, errors.Errorf("unsupported locator: %q", refspec.Locator) } + prefix := strings.TrimPrefix(refspec.Locator, host+"/") + base.Path = path.Join("/v2", prefix) + fetcher := &dockerFetcher{ - base: base, - token: token, + base: base, + client: r.client, + username: username, + secret: secret, } var ( @@ -125,16 +162,13 @@ func (r *dockerResolver) Resolve(ctx context.Context, ref string) (string, ocisp if dgstHeader != "" { if err := dgstHeader.Validate(); err != nil { - if err == nil { - return "", ocispec.Descriptor{}, nil, errors.Errorf("%q in header not a valid digest", dgstHeader) - } return "", ocispec.Descriptor{}, nil, errors.Wrapf(err, "%q in header not a valid digest", dgstHeader) } dgst = dgstHeader } if dgst == "" { - return "", ocispec.Descriptor{}, nil, errors.Wrapf(err, "could not resolve digest for %v", ref) + return "", ocispec.Descriptor{}, nil, errors.Errorf("could not resolve digest for %v", ref) } var ( @@ -143,8 +177,12 @@ func (r *dockerResolver) Resolve(ctx context.Context, ref string) (string, ocisp ) size, err = strconv.ParseInt(sizeHeader, 10, 64) - if err != nil || size < 0 { - return "", ocispec.Descriptor{}, nil, errors.Wrapf(err, "%q in header not a valid size", sizeHeader) + if err != nil { + + return "", ocispec.Descriptor{}, nil, errors.Wrapf(err, "invalid size header: %q", sizeHeader) + } + if size < 0 { + return "", ocispec.Descriptor{}, nil, errors.Errorf("%q in header not a valid size", sizeHeader) } desc := ocispec.Descriptor{ @@ -163,6 +201,11 @@ func (r *dockerResolver) Resolve(ctx context.Context, ref string) (string, ocisp type dockerFetcher struct { base url.URL token string + + client *http.Client + useBasic bool + username string + secret string } func (r *dockerFetcher) Fetch(ctx context.Context, desc ocispec.Descriptor) (io.ReadCloser, error) { @@ -213,13 +256,14 @@ func (r *dockerFetcher) url(ps ...string) string { } func (r *dockerFetcher) doRequest(ctx context.Context, req *http.Request) (*http.Response, error) { + return r.doRequestWithRetries(ctx, req, nil) +} + +func (r *dockerFetcher) doRequestWithRetries(ctx context.Context, req *http.Request, responses []*http.Response) (*http.Response, error) { ctx = log.WithLogger(ctx, log.G(ctx).WithField("url", req.URL.String())) log.G(ctx).WithField("request.headers", req.Header).Debug("fetch content") - if r.token != "" { - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", r.token)) - } - - resp, err := ctxhttp.Do(ctx, http.DefaultClient, req) + r.authorize(req) + resp, err := ctxhttp.Do(ctx, r.client, req) if err != nil { return nil, err } @@ -228,50 +272,119 @@ func (r *dockerFetcher) doRequest(ctx context.Context, req *http.Request) (*http "response.headers": resp.Header, }).Debug("fetch response received") + responses = append(responses, resp) + req, err = r.retryRequest(ctx, req, responses) + if err != nil { + return nil, err + } + if req != nil { + return r.doRequestWithRetries(ctx, req, responses) + } return resp, err } -func getToken(ctx context.Context, scopes ...string) (string, error) { - var ( - u = url.URL{ - Scheme: "https", - Host: "auth.docker.io", - Path: "/token", +func (r *dockerFetcher) authorize(req *http.Request) { + if r.useBasic { + req.SetBasicAuth(r.username, r.secret) + } else if r.token != "" { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", r.token)) + } +} + +func (r *dockerFetcher) retryRequest(ctx context.Context, req *http.Request, responses []*http.Response) (*http.Request, error) { + if len(responses) > 5 { + return nil, nil + } + last := responses[len(responses)-1] + if last.StatusCode == http.StatusUnauthorized { + log.G(ctx).WithField("header", last.Header.Get("WWW-Authenticate")).Debug("Unauthorized") + for _, c := range parseAuthHeader(last.Header) { + if c.scheme == bearerAuth { + if errStr := c.parameters["error"]; errStr != "" { + // TODO: handle expired case + return nil, errors.Wrapf(ErrInvalidAuthorization, "server message: %s", errStr) + } + if err := r.setTokenAuth(ctx, c.parameters); err != nil { + return nil, err + } + return req, nil + } else if c.scheme == basicAuth { + if r.username != "" && r.secret != "" { + r.useBasic = true + } + return req, nil + } } - - q = url.Values{ - "scope": scopes, - "service": []string{"registry.docker.io"}, // usually comes from auth challenge + return nil, nil + } else if last.StatusCode == http.StatusMethodNotAllowed && req.Method == http.MethodHead { + // Support registries which have not properly implemented the HEAD method for + // manifests endpoint + if strings.Contains(req.URL.Path, "/manifests/") { + // TODO: copy request? + req.Method = http.MethodGet + return req, nil } - ) + } - u.RawQuery = q.Encode() + // TODO: Handle 50x errors accounting for attempt history + return nil, nil +} - log.G(ctx).WithField("token.url", u.String()).Debug("requesting token") - resp, err := ctxhttp.Get(ctx, http.DefaultClient, u.String()) +func isManifestAccept(h http.Header) bool { + for _, ah := range h[textproto.CanonicalMIMEHeaderKey("Accept")] { + switch ah { + case images.MediaTypeDockerSchema2Manifest: + fallthrough + case images.MediaTypeDockerSchema2ManifestList: + fallthrough + case ocispec.MediaTypeImageManifest: + fallthrough + case ocispec.MediaTypeImageIndex: + return true + } + } + return false +} + +func (r *dockerFetcher) setTokenAuth(ctx context.Context, params map[string]string) error { + realm, ok := params["realm"] + if !ok { + return errors.New("no realm specified for token auth challenge") + } + + realmURL, err := url.Parse(realm) if err != nil { - return "", err - } - defer resp.Body.Close() - - if resp.StatusCode > 299 { - return "", errors.Errorf("unexpected status code: %v %v", resp.StatusCode, resp.Status) + return fmt.Errorf("invalid token auth challenge realm: %s", err) } - p, err := ioutil.ReadAll(resp.Body) - if err != nil { - return "", err + to := tokenOptions{ + realm: realmURL.String(), + service: params["service"], } - var tokenResponse struct { - Token string `json:"token"` + scope, ok := params["scope"] + if !ok { + return errors.Errorf("no scope specified for token auth challenge") } - if err := json.Unmarshal(p, &tokenResponse); err != nil { - return "", err + // TODO: Get added scopes from context + to.scopes = []string{scope} + + if r.secret != "" { + // Credential information is provided, use oauth POST endpoint + r.token, err = r.fetchTokenWithOAuth(ctx, to) + if err != nil { + return errors.Wrap(err, "failed to fetch oauth token") + } + } else { + // Do request anonymously + r.token, err = r.getToken(ctx, to) + if err != nil { + return errors.Wrap(err, "failed to fetch anonymous token") + } } - return tokenResponse.Token, nil + return nil } // getV2URLPaths generates the candidate urls paths for the object based on the @@ -291,3 +404,125 @@ func getV2URLPaths(desc ocispec.Descriptor) ([]string, error) { return urls, nil } + +type tokenOptions struct { + realm string + service string + scopes []string +} + +type postTokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + IssuedAt time.Time `json:"issued_at"` + Scope string `json:"scope"` +} + +func (r *dockerFetcher) fetchTokenWithOAuth(ctx context.Context, to tokenOptions) (string, error) { + form := url.Values{} + form.Set("scope", strings.Join(to.scopes, " ")) + form.Set("service", to.service) + // TODO: Allow setting client_id + form.Set("client_id", "containerd-dist-tool") + + if r.username == "" { + form.Set("grant_type", "refresh_token") + form.Set("refresh_token", r.secret) + } else { + form.Set("grant_type", "password") + form.Set("username", r.username) + form.Set("password", r.secret) + } + + resp, err := ctxhttp.PostForm(ctx, r.client, to.realm, form) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode == 405 && r.username != "" { + // It would be nice if registries would implement the specifications + return r.getToken(ctx, to) + } else if resp.StatusCode < 200 || resp.StatusCode >= 400 { + b, _ := ioutil.ReadAll(resp.Body) + log.G(ctx).WithFields(logrus.Fields{ + "status": resp.Status, + "body": string(b), + }).Debugf("token request failed") + // TODO: handle error body and write debug output + return "", errors.Errorf("unexpected status: %s", resp.Status) + } + + decoder := json.NewDecoder(resp.Body) + + var tr postTokenResponse + if err = decoder.Decode(&tr); err != nil { + return "", fmt.Errorf("unable to decode token response: %s", err) + } + + return tr.AccessToken, nil +} + +type getTokenResponse struct { + Token string `json:"token"` + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + IssuedAt time.Time `json:"issued_at"` + RefreshToken string `json:"refresh_token"` +} + +// getToken fetches a token using a GET request +func (r *dockerFetcher) getToken(ctx context.Context, to tokenOptions) (string, error) { + req, err := http.NewRequest("GET", to.realm, nil) + if err != nil { + return "", err + } + + reqParams := req.URL.Query() + + if to.service != "" { + reqParams.Add("service", to.service) + } + + for _, scope := range to.scopes { + reqParams.Add("scope", scope) + } + + if r.secret != "" { + req.SetBasicAuth(r.username, r.secret) + } + + req.URL.RawQuery = reqParams.Encode() + + resp, err := ctxhttp.Do(ctx, r.client, req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 400 { + // TODO: handle error body and write debug output + return "", errors.Errorf("unexpected status: %s", resp.Status) + } + + decoder := json.NewDecoder(resp.Body) + + var tr getTokenResponse + if err = decoder.Decode(&tr); err != nil { + return "", fmt.Errorf("unable to decode token response: %s", err) + } + + // `access_token` is equivalent to `token` and if both are specified + // the choice is undefined. Canonicalize `access_token` by sticking + // things in `token`. + if tr.AccessToken != "" { + tr.Token = tr.AccessToken + } + + if tr.Token == "" { + return "", ErrNoToken + } + + return tr.Token, nil +}