1// SPDX-License-Identifier: MIT
2
3package registryproxy
4
5import (
6 "context"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "io"
11 "net"
12 "net/http"
13 "net/url"
14 "sync"
15 "time"
16)
17
18type upstreamError struct{ status int }
19
20func (e upstreamError) Error() string { return fmt.Sprintf("registry upstream status %d", e.status) }
21
22// unavailable reports an upstream that cannot answer now (down, timing out,
23// failing or rate limiting): a known tag may then be served stale.
24func unavailable(err error) bool {
25 var e upstreamError
26 if errors.As(err, &e) {
27 return e.status >= http.StatusInternalServerError || e.status == http.StatusTooManyRequests
28 }
29 var network net.Error
30 return errors.As(err, &network) || errors.Is(err, context.DeadlineExceeded)
31}
32
33func proxyStatus(err error) int {
34 var e upstreamError
35 if errors.As(err, &e) && (e.status == http.StatusUnauthorized || e.status == http.StatusForbidden || e.status == http.StatusNotFound) {
36 return http.StatusNotFound
37 }
38 return http.StatusBadGateway
39}
40
41type bearer struct {
42 value string
43 until time.Time
44}
45
46type upstream struct {
47 base, auth string
48 client *http.Client
49 blobClient *http.Client
50 requestTimeout, blobIdle time.Duration
51 mu sync.Mutex
52 tokens map[string]bearer
53}
54
55func (u *upstream) token(ctx context.Context, name string, refresh bool) (string, error) {
56 // Do not hold the token map lock over network I/O: an unavailable auth
57 // endpoint must not queue unrelated repositories behind its timeout.
58 // No guest credentials or challenge-controlled URLs are accepted.
59 u.mu.Lock()
60 t, ok := u.tokens[name]
61 u.mu.Unlock()
62 if ok && !refresh && time.Now().Before(t.until) {
63 return t.value, nil
64 }
65 a, err := url.Parse(u.auth)
66 if err != nil {
67 return "", err
68 }
69 q := a.Query()
70 q.Set("service", "registry.docker.io")
71 q.Set("scope", "repository:"+name+":pull")
72 a.RawQuery = q.Encode()
73 req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.String(), nil)
74 if err != nil {
75 return "", err
76 }
77 resp, err := u.client.Do(req)
78 if err != nil {
79 return "", err
80 }
81 defer func() { _ = resp.Body.Close() }()
82 if resp.StatusCode != http.StatusOK {
83 return "", upstreamError{resp.StatusCode}
84 }
85 var data struct {
86 Token string `json:"token"`
87 AccessToken string `json:"access_token"`
88 Expires int64 `json:"expires_in"`
89 }
90 b, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20+1))
91 if err != nil {
92 return "", err
93 }
94 if len(b) > 1<<20 {
95 return "", errors.New("registry token response too large")
96 }
97 if err = json.Unmarshal(b, &data); err != nil {
98 return "", err
99 }
100 if data.Token == "" {
101 data.Token = data.AccessToken
102 }
103 if data.Token == "" {
104 return "", errors.New("empty registry token")
105 }
106 if data.Expires <= 0 {
107 data.Expires = 60
108 }
109 if data.Expires > 86400 {
110 data.Expires = 86400
111 }
112 u.mu.Lock()
113 defer u.mu.Unlock()
114 if len(u.tokens) >= 1024 {
115 clear(u.tokens)
116 }
117 u.tokens[name] = bearer{data.Token, time.Now().Add(time.Duration(data.Expires) * time.Second)}
118 return data.Token, nil
119}
120
121// authorized calls send with a bearer token for name, and once more with a
122// fresh token after a 401 or 403. Each token request is bounded by
123// RequestTimeout (and ctx). It returns a 200 response; any other closes the
124// body and is an upstreamError.
125func (u *upstream) authorized(ctx context.Context, name string, send func(tok string) (*http.Response, error)) (*http.Response, error) {
126 for attempt := 0; attempt < 2; attempt++ {
127 authCtx, cancel := context.WithTimeout(ctx, u.requestTimeout)
128 tok, err := u.token(authCtx, name, attempt > 0)
129 cancel()
130 if err != nil {
131 return nil, err
132 }
133 resp, err := send(tok)
134 if err != nil {
135 return nil, err
136 }
137 if resp.StatusCode == http.StatusOK {
138 return resp, nil
139 }
140 _ = resp.Body.Close()
141 if (resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden) && attempt == 0 {
142 continue
143 }
144 return nil, upstreamError{resp.StatusCode}
145 }
146 return nil, upstreamError{http.StatusUnauthorized}
147}
148
149// request is a metadata request (manifests, blob HEAD) bounded by ctx. Manifests
150// use the client that never follows redirects; a blob HEAD uses the blob client,
151// because Docker Hub answers it with a redirect to its CDN.
152func (u *upstream) request(ctx context.Context, method, name string, k kind, ref, accept string) (*http.Response, error) {
153 client := u.client
154 if k == kindBlob {
155 client = u.blobClient
156 }
157 return u.authorized(ctx, name, func(tok string) (*http.Response, error) {
158 req, err := http.NewRequestWithContext(ctx, method, u.base+"/v2/"+name+"/"+k.dir()+"/"+ref, nil)
159 if err != nil {
160 return nil, err
161 }
162 req.Header.Set("Authorization", "Bearer "+tok)
163 if accept != "" {
164 req.Header.Set("Accept", accept)
165 }
166 // Do not forward guest Authorization, cookies, query parameters or Range.
167 return client.Do(req)
168 })
169}