forrestlee commited on
Commit
8ea6360
·
verified ·
1 Parent(s): 7f8a073

Create dockerhub-public-proxy.pl

Browse files
Files changed (1) hide show
  1. dockerhub-public-proxy.pl +203 -0
dockerhub-public-proxy.pl ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env perl
2
+ use Mojo::Base -strict, -signatures;
3
+
4
+ # see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control for a good reference on Cache-Control headers in general
5
+
6
+ use Mojo::UserAgent;
7
+
8
+ # optional configuration for Docker Hub credentials to help with rate limiting
9
+ # WARNING: this will allow unauthenticated access for anyone who can hit the proxy to anything this user has access to, so please use it with care!!
10
+ # https://docs.docker.com/docker-hub/download-rate-limit/
11
+ # https://github.com/docker/hub-feedback/issues/1907
12
+ my @creds = split(/\n/, Mojo::Util::trim($ENV{DOCKERHUB_PUBLIC_PROXY_CREDENTIALS} // ''));
13
+
14
+ # max_response_size justification: https://github.com/opencontainers/distribution-spec/pull/293#issuecomment-1452780554 (allowing slightly more than registries should, just to be safe)
15
+ my $ua = Mojo::UserAgent->new->max_redirects(0)->connect_timeout(20)->inactivity_timeout(20)->max_response_size(5 * 1024 * 1024);
16
+ $ua->transactor->name($ENV{DOCKERHUB_PUBLIC_PROXY_USER_AGENT} // 'https://github.com/tianon/dockerhub-public-proxy');
17
+
18
+ sub _cred {
19
+ state $i = 0;
20
+ return undef unless @creds;
21
+ my $cred = $creds[$i];
22
+ $i = ($i + 1) % @creds;
23
+ return $cred;
24
+ }
25
+
26
+ sub _registry_req_p {
27
+ my $tries = shift;
28
+ my $method = shift;
29
+ my $repo = shift;
30
+ my $url = shift;
31
+ my %extHeaders = @_;
32
+
33
+ --$tries;
34
+ my $lastTry = $tries < 1;
35
+
36
+ my %headers = (
37
+ %extHeaders,
38
+ );
39
+
40
+ state %tokens;
41
+ if (my $token = $tokens{$repo}) {
42
+ $headers{Authorization} = "Bearer $token";
43
+ }
44
+
45
+ my $methodP = lc $method . '_p';
46
+ return $ua->$methodP($url, \%headers)->then(sub {
47
+ my $tx = shift;
48
+ if (!$lastTry && $tx->res->code == 401) {
49
+ # "Unauthorized" -- we must need to go fetch a token for this registry request (so let's go do that, then retry the original registry request)
50
+ my $auth = $tx->res->headers->www_authenticate;
51
+ die "unexpected WWW-Authenticate header: $auth" unless $auth =~ m{ ^ Bearer \s+ (\S.*) $ }x;
52
+ my $realm = $1;
53
+ my $authUrl = Mojo::URL->new;
54
+ while ($realm =~ m{
55
+ # key="val",
56
+ ([^=]+)
57
+ =
58
+ "([^"]+)"
59
+ ,?
60
+ }xg) {
61
+ my ($key, $val) = ($1, $2);
62
+ next if $key eq 'error';
63
+ if ($key eq 'realm') {
64
+ $authUrl->base(Mojo::URL->new($val));
65
+ } else {
66
+ $authUrl->query->append($key => $val);
67
+ }
68
+ }
69
+ $authUrl = $authUrl->to_abs;
70
+ if (my $cred = _cred) {
71
+ # see description of DOCKERHUB_PUBLIC_PROXY_CREDENTIALS above
72
+ $authUrl->userinfo($cred);
73
+ }
74
+ return $ua->get_p($authUrl->to_unsafe_string)->then(sub {
75
+ my $tokenTx = shift;
76
+ if (my $error = $tokenTx->error) {
77
+ die "failed to fetch token for $repo: " . ($error->{code} ? $error->{code} . ' -- ' : '') . $error->{message};
78
+ }
79
+ $tokens{$repo} = $tokenTx->res->json->{token};
80
+ return _registry_req_p($tries, $method, $repo, $url, %extHeaders);
81
+ });
82
+ }
83
+
84
+ return $tx;
85
+ });
86
+ }
87
+ sub registry_req_p {
88
+ my $method = shift;
89
+ my $repo = shift;
90
+ my $url = shift;
91
+ my %extHeaders = @_;
92
+
93
+ $url = "https://registry-1.docker.io/v2/$repo/$url";
94
+
95
+ # we allow exactly two tries for a given request to account for at most one auth round-trip + one retry
96
+ return _registry_req_p(2, $method, $repo, $url, %extHeaders);
97
+ }
98
+
99
+ use Mojolicious::Lite;
100
+
101
+ any [ 'GET', 'HEAD' ] => '/v2/#org/#repo/*url' => sub ($c) {
102
+ $c->render_later;
103
+
104
+ my $repo = $c->param('org') . '/' . $c->param('repo');
105
+ my $url = $c->param('url');
106
+
107
+ # ignore request headers (they can cause issues with returning the wrong "Location:" redirects thanks to X-Forwarded-*, for example)
108
+ my %headers = (
109
+ # upgrade useless Accept: header so "curl" is useful OOTB instead of returning a v1 manifest
110
+ # ... and clients that don't accept manifest lists so they don't screw up clients that do (we don't support clients that don't support manifest lists)
111
+ Accept => [
112
+ 'application/vnd.docker.distribution.manifest.list.v2+json',
113
+ 'application/vnd.docker.distribution.manifest.v2+json',
114
+ 'application/vnd.oci.image.index.v1+json',
115
+ 'application/vnd.oci.image.manifest.v1+json',
116
+ 'application/vnd.oci.image.config.v1+json',
117
+ '*/*',
118
+ ],
119
+ );
120
+
121
+ my $cacheable = $url =~ m!/sha256:!;
122
+ my $tagRequest = !$cacheable && $url =~ m!^manifests/!;
123
+
124
+ return registry_req_p(($tagRequest ? 'HEAD' : $c->req->method), $repo, $url, %headers)->then(sub {
125
+ my $tx = shift;
126
+
127
+ $c->res->headers->from_hash({})->from_hash($tx->res->headers->to_hash(1));
128
+
129
+ # TODO check for $digest sooner and set $cacheable based on whether the request URL contains the digest returned?
130
+
131
+ my $maxAge = 0;
132
+ if ($cacheable) {
133
+ # looks like a content-addressable digest -- literally by definition, that content can't change, so let's tell the client that via cache-control (if the response is something resembling success, anyhow)
134
+ if ($tx->res->code == 200 || $tx->res->code == 301 || $tx->res->code == 308) {
135
+ # 200 = success, 301 = Moved Permanently, 308 = Permanent Redirect
136
+ $maxAge = 365 * 24 * 60 * 60;
137
+ # https://stackoverflow.com/a/25201898/433558
138
+ }
139
+ elsif ($tx->res->code >= 300 && $tx->res->code < 400) {
140
+ # other redirects are likely somewhat less cacheable (temporary), so let's dial it back
141
+ $maxAge = 30 * 60;
142
+ }
143
+ }
144
+
145
+ if ($maxAge) {
146
+ $c->res->headers->cache_control('public, max-age=' . $maxAge);
147
+ }
148
+ else {
149
+ # don't cache non-digests
150
+ $c->res->headers->cache_control('no-cache');
151
+ }
152
+
153
+ my $digest = $tx->res->headers->header('docker-content-digest');
154
+ if (
155
+ $tx->res->code == 200
156
+ && !$c->res->headers->content_length
157
+ && $digest
158
+ && $digest ne 'sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' # this is the sha256 of the empty string (zero bytes)
159
+ ) {
160
+ $c->res->headers->remove('Content-Length');
161
+ return $c->render(status => 500, data => "response (unexpectedly) missing content-length (digest $digest)");
162
+ }
163
+
164
+ # it doesn't make any sense to redirect HEAD requests -- they're not very cacheable anyhow, so all that does is double the number of requests-per-request
165
+ # HOWEVER, the spec says that HEAD and GET *must* be interchangeable, so there are likely still glitches when they aren't even when we're asking for no-cache
166
+ if ($tagRequest) {
167
+ if ($c->req->method ne 'HEAD') {
168
+ # if we converted the request to HEAD, we need to axe the Content-Length header value because we don't have the content that goes with it :D
169
+ $c->res->headers->content_length(0);
170
+ # (and if we're not about to redirect, we're going to error in some way, either our own error or passing along upstream's error, like 404)
171
+ }
172
+
173
+ if ($digest) {
174
+ $c->res->headers->content_length(0);
175
+ return $c->redirect_to("/v2/$repo/manifests/$digest");
176
+ }
177
+ elsif ($tx->res->code == 200) {
178
+ $c->res->headers->remove('Content-Length');
179
+ return $c->render(status => 500, data => 'we converted the request to a HEAD, but we need to generate a redirect and did not get a Docker-Content-Digest header to tell us where to redirect to');
180
+ }
181
+ }
182
+
183
+ $c->render(data => $tx->res->body, status => $tx->res->code);
184
+ }, sub {
185
+ $c->reply->exception(@_);
186
+ });
187
+ };
188
+ #any '/v2/*url' => { url => '' } => sub {
189
+ # my $c = shift;
190
+ # return $c->redirect_to('https://registry-1.docker.io/v2/' . $c->param('url'));
191
+ #};
192
+ get '/v2/' => sub ($c) {
193
+ # this is often used as a "ping" endpoint (https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#determining-support)
194
+ # let's let this be cached for 24h (possibly reused stale cache up to 48h)? 🤷 we could set this even longer but this feels like a good balance between *too* long if it ever does need to change and too short wasting cycles revalidating all the time
195
+ my $twentyFourHours = 24 * 60 * 60;
196
+ $c->res->headers->cache_control("public, max-age=$twentyFourHours, stale-while-revalidate=$twentyFourHours, stale-if-error=$twentyFourHours");
197
+ $c->render(json => {});
198
+ };
199
+ get '/' => sub ($c) {
200
+ return $c->redirect_to('https://github.com/tianon/dockerhub-public-proxy');
201
+ };
202
+
203
+ app->start;