Spaces:
Paused
Paused
Create dockerhub-public-proxy.pl
Browse files- 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;
|