v0.6.0 aba298d

upstream.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 11

↑↓ moveenter openesc close