1// SPDX-License-Identifier: MIT
2
3package registryproxy
4
5import (
6 "fmt"
7 "io"
8 "net/http"
9 "net/http/httptest"
10 "os"
11 "path/filepath"
12 "strings"
13 "sync/atomic"
14 "testing"
15 "time"
16)
17
18// A blob body has no total deadline: a transfer that keeps moving may last
19// far longer than RequestTimeout and BlobIdleTimeout.
20func TestBlobTransferOutlivesRequestTimeout(t *testing.T) {
21 body := strings.Repeat("layer", 4000)
22 srv := upstreamWith(t, slowBlob(body, 20, 25*time.Millisecond))
23 p := proxyWith(t, srv, Config{RequestTimeout: 100 * time.Millisecond, BlobIdleTimeout: 150 * time.Millisecond})
24 start := time.Now()
25 w := request(t, p, http.MethodGet, "/v2/library/alpine/blobs/"+sum(body))
26 if w.Code != http.StatusOK || w.Body.String() != body {
27 t.Fatalf("blob: %d", w.Code)
28 }
29 if d := time.Since(start); d < 300*time.Millisecond {
30 t.Fatalf("transfer took %v, not longer than the timeouts", d)
31 }
32}
33
34// A blob body that stops moving is cut after BlobIdleTimeout, and auth,
35// manifests and HEAD keep their total RequestTimeout.
36func TestStalledUpstreamTimesOut(t *testing.T) {
37 body := strings.Repeat("layer", 4000)
38 srv := upstreamWith(t, func(w http.ResponseWriter, r *http.Request) {
39 if strings.Contains(r.URL.Path, "/blobs/") && r.Method == http.MethodGet {
40 w.Header().Set("Content-Length", fmt.Sprint(len(body)))
41 _, _ = io.WriteString(w, body[:100])
42 w.(http.Flusher).Flush()
43 }
44 select { // a stalled body, or headers that never come
45 case <-r.Context().Done():
46 case <-time.After(5 * time.Second):
47 }
48 })
49 p := proxyWith(t, srv, Config{RequestTimeout: 100 * time.Millisecond, BlobIdleTimeout: 150 * time.Millisecond})
50 for _, tc := range []struct{ method, path string }{
51 {http.MethodGet, "/v2/library/alpine/blobs/" + sum(body)},
52 {http.MethodHead, "/v2/library/alpine/blobs/" + sum(body)},
53 {http.MethodGet, "/v2/library/alpine/manifests/3"},
54 } {
55 start := time.Now()
56 if w := request(t, p, tc.method, tc.path); w.Code != http.StatusBadGateway {
57 t.Errorf("%s %s: %d", tc.method, tc.path, w.Code)
58 }
59 if d := time.Since(start); d > 2*time.Second {
60 t.Errorf("%s %s: cut after %v", tc.method, tc.path, d)
61 }
62 }
63 if _, err := os.Stat(filepath.Join(p.s.dir, kindBlob.key(sum(body)[7:]))); !os.IsNotExist(err) {
64 t.Fatal("partial blob stored")
65 }
66}
67
68// Auth and manifest requests never follow a redirect.
69func TestMetadataRedirectsNotFollowed(t *testing.T) {
70 var elsewhere atomic.Int32
71 other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
72 elsewhere.Add(1)
73 _, _ = io.WriteString(w, `{"token":"public"}`)
74 }))
75 t.Cleanup(other.Close)
76 for _, redirectAuth := range []bool{true, false} {
77 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
78 if r.URL.Path == "/token" && !redirectAuth {
79 _, _ = io.WriteString(w, `{"token":"public"}`)
80 return
81 }
82 http.Redirect(w, r, other.URL+r.URL.Path, http.StatusTemporaryRedirect)
83 }))
84 p := proxyWith(t, srv, Config{})
85 for _, ref := range []string{"3", sum(manifestBody)} {
86 if w := request(t, p, http.MethodGet, "/v2/library/alpine/manifests/"+ref); w.Code != http.StatusBadGateway {
87 t.Errorf("auth redirect %v, ref %s: %d", redirectAuth, ref, w.Code)
88 }
89 }
90 srv.Close()
91 }
92 if elsewhere.Load() != 0 {
93 t.Fatalf("%d redirects followed", elsewhere.Load())
94 }
95}
96
97// Blob requests, HEAD included, follow the CDN redirect without the bearer
98// token.
99func TestBlobRedirectStripsBearer(t *testing.T) {
100 var cdnHits atomic.Int32
101 cdn := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
102 cdnHits.Add(1)
103 if r.Header.Get("Authorization") != "" {
104 t.Error("bearer sent to the CDN")
105 }
106 w.Header().Set("Content-Length", fmt.Sprint(len(blobBody)))
107 if r.Method == http.MethodGet {
108 _, _ = io.WriteString(w, blobBody)
109 }
110 }))
111 t.Cleanup(cdn.Close)
112 srv := upstreamWith(t, func(w http.ResponseWriter, r *http.Request) {
113 if r.Header.Get("Authorization") != "Bearer public" {
114 t.Error("no bearer on the registry")
115 }
116 http.Redirect(w, r, cdn.URL+"/layer", http.StatusTemporaryRedirect)
117 })
118 p := proxyWith(t, srv, Config{})
119 path := "/v2/library/alpine/blobs/" + sum(blobBody)
120 if w := request(t, p, http.MethodHead, path); w.Code != http.StatusOK || w.Header().Get("Content-Length") != fmt.Sprint(len(blobBody)) {
121 t.Fatalf("HEAD: %d %v", w.Code, w.Header())
122 }
123 if w := request(t, p, http.MethodGet, path); w.Code != http.StatusOK || w.Body.String() != blobBody {
124 t.Fatalf("blob: %d", w.Code)
125 }
126 if cdnHits.Load() != 2 {
127 t.Fatalf("CDN hits %d", cdnHits.Load())
128 }
129}
130
131// A 401 or 403 is retried once with a fresh token; a second one is a 404, so
132// that dockerd falls back to Docker Hub with the job's credentials.
133func TestUnauthorizedRetriedOnce(t *testing.T) {
134 for _, path := range []string{"/v2/library/alpine/manifests/3", "/v2/library/alpine/blobs/" + sum(blobBody)} {
135 f := fake(t)
136 p := proxyFor(t, f)
137 f.status.Store(http.StatusUnauthorized)
138 if w := request(t, p, http.MethodGet, path); w.Code != http.StatusNotFound {
139 t.Fatalf("%s: %d", path, w.Code)
140 }
141 if f.tokens.Load() != 2 {
142 t.Fatalf("%s: %d token requests, want a fresh one", path, f.tokens.Load())
143 }
144 }
145}
146
147// When upstream is down, fails or rate-limits (429), a known tag is served
148// stale; an unknown one is an error.
149func TestStaleTagWhenUpstreamUnavailable(t *testing.T) {
150 for _, tc := range []struct {
151 name string
152 status int32 // 0: the server is closed
153 }{{"rate limited", http.StatusTooManyRequests}, {"failing", http.StatusServiceUnavailable}, {"down", 0}} {
154 t.Run(tc.name, func(t *testing.T) {
155 f := fake(t)
156 p := proxyFor(t, f)
157 if w := request(t, p, http.MethodGet, "/v2/library/alpine/manifests/3"); w.Code != http.StatusOK {
158 t.Fatal(w.Code)
159 }
160 if tc.status == 0 {
161 f.server.Close()
162 }
163 f.status.Store(tc.status)
164 if w := request(t, p, http.MethodGet, "/v2/library/alpine/manifests/3"); w.Code != http.StatusOK || w.Body.String() != manifestBody {
165 t.Fatalf("stale: %d %s", w.Code, w.Body)
166 }
167 if w := request(t, p, http.MethodGet, "/v2/library/alpine/manifests/unknown"); w.Code != http.StatusBadGateway {
168 t.Fatalf("unknown: %d", w.Code)
169 }
170 })
171 }
172}