Skip to content

Commit 4a6ae78

Browse files
committedMar 10, 2025·
pull fallback: allow anonymous auth with docker.io and upload manifest from fallback should work
1 parent 518ea48 commit 4a6ae78

File tree

6 files changed

+137
-55
lines changed

6 files changed

+137
-55
lines changed
 

‎README.md

+8
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,14 @@ the target registry and setup the credentials.
110110

111111
**Never put a registry password/token inside the wrangler.toml, please always use `wrangler secrets put`**
112112

113+
You can also use docker.io with anonymous authentication:
114+
```
115+
REGISTRIES_JSON = "[{ \"registry\": \"https://index.docker.io/\" }]"
116+
```
117+
118+
You can also set your `docker.io` credentials in the configuration to not have any rate-limiting.
119+
120+
113121
### Known limitations
114122

115123
Right now there is some limitations with this container registry.

‎src/registry/http.ts

+37-19
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ type AuthContext = {
2424
scope: string;
2525
};
2626

27+
export function isDockerDotIO(url: URL): boolean {
28+
const regex = /^https:\/\/([\w\d]+\.)?docker\.io$/;
29+
return regex.test(url.origin);
30+
}
31+
2732
type HTTPContext = {
2833
// The auth context for this request
2934
authContext: AuthContext;
@@ -149,14 +154,35 @@ export class RegistryHTTPClient implements Registry {
149154
}
150155

151156
authBase64(): string {
157+
const configuration = this.configuration;
158+
if (configuration.username === undefined) {
159+
return "";
160+
}
161+
152162
return btoa(this.configuration.username + ":" + this.password());
153163
}
154164

155165
password(): string {
156-
return (this.env as unknown as Record<string, string>)[this.configuration.password_env] ?? "";
166+
const configuration = this.configuration;
167+
if (configuration.username === undefined) {
168+
return "";
169+
}
170+
171+
return (this.env as unknown as Record<string, string>)[configuration.password_env] ?? "";
157172
}
158173

159174
async authenticate(namespace: string): Promise<HTTPContext> {
175+
const emptyAuthentication = {
176+
authContext: {
177+
authType: "none",
178+
realm: "",
179+
scope: "",
180+
service: this.url.host,
181+
},
182+
repository: this.url.pathname,
183+
accessToken: "",
184+
} as const;
185+
160186
const res = await fetch(`${this.url.protocol}//${this.url.host}/v2/`, {
161187
headers: {
162188
"User-Agent": "Docker-Client/24.0.5 (linux)",
@@ -165,16 +191,7 @@ export class RegistryHTTPClient implements Registry {
165191
});
166192

167193
if (res.ok) {
168-
return {
169-
authContext: {
170-
authType: "none",
171-
realm: "",
172-
scope: "",
173-
service: this.url.host,
174-
},
175-
repository: this.url.pathname,
176-
accessToken: "",
177-
};
194+
return emptyAuthentication;
178195
}
179196

180197
if (res.status !== 401) {
@@ -212,11 +229,12 @@ export class RegistryHTTPClient implements Registry {
212229
async authenticateBearerSimple(ctx: AuthContext, params: URLSearchParams): Promise<Response> {
213230
params.delete("password");
214231
console.log("sending authentication parameters:", ctx.realm + "?" + params.toString());
232+
215233
return await fetch(ctx.realm + "?" + params.toString(), {
216234
headers: {
217-
"Authorization": "Basic " + this.authBase64(),
218235
"Accept": "application/json",
219236
"User-Agent": "Docker-Client/24.0.5 (linux)",
237+
...(this.configuration.username !== undefined ? { Authorization: "Basic " + this.authBase64() } : {}),
220238
},
221239
});
222240
}
@@ -227,8 +245,8 @@ export class RegistryHTTPClient implements Registry {
227245
// explicitely include that we don't want an offline_token.
228246
scope: `repository:${ctx.scope}:pull,push`,
229247
client_id: "r2registry",
230-
grant_type: "password",
231-
password: this.password(),
248+
grant_type: this.configuration.username === undefined ? "none" : "password",
249+
password: this.configuration.username === undefined ? "" : this.password(),
232250
});
233251
let response = await fetch(ctx.realm, {
234252
headers: {
@@ -313,7 +331,7 @@ export class RegistryHTTPClient implements Registry {
313331
}
314332

315333
async manifestExists(name: string, tag: string): Promise<CheckManifestResponse | RegistryError> {
316-
const namespace = name.includes("/") ? name : `library/${name}`;
334+
const namespace = name.includes("/") || !isDockerDotIO(this.url) ? name : `library/${name}`;
317335
try {
318336
const ctx = await this.authenticate(namespace);
319337
const req = ctxIntoRequest(ctx, this.url, "HEAD", `${namespace}/manifests/${tag}`);
@@ -342,7 +360,7 @@ export class RegistryHTTPClient implements Registry {
342360
}
343361

344362
async getManifest(name: string, digest: string): Promise<GetManifestResponse | RegistryError> {
345-
const namespace = name.includes("/") ? name : `library/${name}`;
363+
const namespace = name.includes("/") || !isDockerDotIO(this.url) ? name : `library/${name}`;
346364
try {
347365
const ctx = await this.authenticate(namespace);
348366
const req = ctxIntoRequest(ctx, this.url, "GET", `${namespace}/manifests/${digest}`);
@@ -374,7 +392,7 @@ export class RegistryHTTPClient implements Registry {
374392
}
375393

376394
async layerExists(name: string, digest: string): Promise<CheckLayerResponse | RegistryError> {
377-
const namespace = name.includes("/") ? name : `library/${name}`;
395+
const namespace = name.includes("/") || !isDockerDotIO(this.url) ? name : `library/${name}`;
378396
try {
379397
const ctx = await this.authenticate(namespace);
380398
const res = await fetch(ctxIntoRequest(ctx, this.url, "HEAD", `${namespace}/blobs/${digest}`));
@@ -414,7 +432,7 @@ export class RegistryHTTPClient implements Registry {
414432
}
415433

416434
async getLayer(name: string, digest: string): Promise<GetLayerResponse | RegistryError> {
417-
const namespace = name.includes("/") ? name : `library/${name}`;
435+
const namespace = name.includes("/") || !isDockerDotIO(this.url) ? name : `library/${name}`;
418436
try {
419437
const ctx = await this.authenticate(namespace);
420438
const req = ctxIntoRequest(ctx, this.url, "GET", `${namespace}/blobs/${digest}`);
@@ -468,7 +486,7 @@ export class RegistryHTTPClient implements Registry {
468486
_namespace: string,
469487
_reference: string,
470488
_readableStream: ReadableStream<any>,
471-
_contentType: string,
489+
{}: {},
472490
): Promise<PutManifestResponse | RegistryError> {
473491
throw new Error("unimplemented");
474492
}

‎src/registry/r2.ts

+20-5
Original file line numberDiff line numberDiff line change
@@ -292,11 +292,23 @@ export class R2Registry implements Registry {
292292
namespace: string,
293293
reference: string,
294294
readableStream: ReadableStream<any>,
295-
contentType: string,
295+
{
296+
contentType,
297+
checkLayers,
298+
}: {
299+
contentType: string;
300+
checkLayers?: boolean;
301+
},
296302
): Promise<PutManifestResponse | RegistryError> {
297303
const key = await this.gc.markForInsertion(namespace);
298304
try {
299-
return this.putManifestInner(namespace, reference, readableStream, contentType);
305+
return this.putManifestInner(
306+
namespace,
307+
reference,
308+
readableStream,
309+
contentType,
310+
checkLayers !== undefined ? checkLayers === true : true,
311+
);
300312
} finally {
301313
// if this fails, at some point it will be expired
302314
await this.gc.cleanInsertion(namespace, key);
@@ -308,6 +320,7 @@ export class R2Registry implements Registry {
308320
reference: string,
309321
readableStream: ReadableStream<any>,
310322
contentType: string,
323+
checkLayers: boolean,
311324
): Promise<PutManifestResponse | RegistryError> {
312325
const gcMarker = await this.gc.getGCMarker(name);
313326
const env = this.env;
@@ -322,8 +335,10 @@ export class R2Registry implements Registry {
322335
const text = await blob.text();
323336
const manifestJSON = JSON.parse(text);
324337
const manifest = manifestSchema.parse(manifestJSON);
325-
const verifyManifestErr = await this.verifyManifest(name, manifest);
326-
if (verifyManifestErr !== null) return { response: verifyManifestErr };
338+
if (checkLayers) {
339+
const verifyManifestErr = await this.verifyManifest(name, manifest);
340+
if (verifyManifestErr !== null) return { response: verifyManifestErr };
341+
}
327342

328343
if (!(await this.gc.checkCanInsertData(name, gcMarker))) {
329344
console.error("Manifest can't be uploaded as there is/was a garbage collection going");
@@ -774,7 +789,7 @@ export class R2Registry implements Registry {
774789
if (hashedState === null || !hashedState.state) {
775790
return {
776791
response: new Response(null, { status: 404 }),
777-
}
792+
};
778793
}
779794
const state = hashedState.state;
780795

‎src/registry/registry.ts

+21-7
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,23 @@ import z from "zod";
55
import { GarbageCollectionMode } from "./garbage-collector";
66

77
// Defines a registry and how it's configured
8-
const registryConfiguration = z.object({
9-
registry: z.string().url(),
10-
password_env: z.string(),
11-
username: z.string(),
12-
});
13-
8+
const registryConfiguration = z
9+
.object({
10+
registry: z.string().url(),
11+
})
12+
.and(
13+
z
14+
.object({
15+
password_env: z.string(),
16+
username: z.string(),
17+
})
18+
.or(
19+
z.object({
20+
username: z.undefined(),
21+
password: z.undefined(),
22+
}),
23+
),
24+
);
1425
export type RegistryConfiguration = z.infer<typeof registryConfiguration>;
1526

1627
export function registries(env: Env): RegistryConfiguration[] {
@@ -132,7 +143,10 @@ export interface Registry {
132143
namespace: string,
133144
reference: string,
134145
readableStream: ReadableStream<any>,
135-
contentType: string,
146+
options: {
147+
contentType: string;
148+
checkLayers?: boolean;
149+
},
136150
): Promise<PutManifestResponse | RegistryError>;
137151

138152
// starts a new upload

‎src/router.ts

+10-6
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,10 @@ v2Router.head("/:name+/manifests/:reference", async (req, env: Env) => {
149149
}
150150

151151
const [putResponse, err] = await wrap(
152-
env.REGISTRY_CLIENT.putManifest(name, reference, manifestResponse.stream, manifestResponse.contentType),
152+
env.REGISTRY_CLIENT.putManifest(name, reference, manifestResponse.stream, {
153+
contentType: manifestResponse.contentType,
154+
checkLayers: false,
155+
}),
153156
);
154157
if (err) {
155158
console.error("Error sync manifest into client:", errorString(err));
@@ -209,7 +212,10 @@ v2Router.get("/:name+/manifests/:reference", async (req, env: Env, context: Exec
209212
context.waitUntil(
210213
(async () => {
211214
const [response, err] = await wrap(
212-
env.REGISTRY_CLIENT.putManifest(name, reference, s2, getManifestResponse.contentType),
215+
env.REGISTRY_CLIENT.putManifest(name, reference, s2, {
216+
contentType: getManifestResponse.contentType,
217+
checkLayers: false,
218+
}),
213219
);
214220
if (err) {
215221
console.error("Error uploading asynchronously the manifest ", reference, "into main registry");
@@ -243,7 +249,7 @@ v2Router.put("/:name+/manifests/:reference", async (req, env: Env) => {
243249

244250
const { name, reference } = req.params;
245251
const [res, err] = await wrap<PutManifestResponse | RegistryError, Error>(
246-
env.REGISTRY_CLIENT.putManifest(name, reference, req.body!, req.headers.get("Content-Type")!),
252+
env.REGISTRY_CLIENT.putManifest(name, reference, req.body!, { contentType: req.headers.get("Content-Type")! }),
247253
);
248254
if (err) {
249255
console.error("Error putting manifest:", errorString(err));
@@ -563,9 +569,7 @@ v2Router.get("/:name+/tags/list", async (req, env: Env) => {
563569
cursor: tags.cursor,
564570
});
565571
// Filter out sha256 manifest
566-
manifestTags = manifestTags.concat(
567-
tags.objects.filter((tag) => !tag.key.startsWith(`${name}/manifests/sha256:`)),
568-
);
572+
manifestTags = manifestTags.concat(tags.objects.filter((tag) => !tag.key.startsWith(`${name}/manifests/sha256:`)));
569573
}
570574

571575
const keys = manifestTags.map((object) => object.key.split("/").pop()!);

‎test/index.test.ts

+41-18
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import { RegistryTokens } from "../src/token";
66
import { RegistryAuthProtocolTokenPayload } from "../src/auth";
77
import { registries } from "../src/registry/registry";
8-
import { RegistryHTTPClient } from "../src/registry/http";
8+
import { isDockerDotIO, RegistryHTTPClient } from "../src/registry/http";
99
import { encode } from "@cfworker/base64url";
1010
import { ManifestSchema } from "../src/manifest";
1111
import { limit } from "../src/chunk";
@@ -112,10 +112,12 @@
112112
}
113113

114114
async function fetch(r: Request): Promise<Response> {
115-
r.headers.append("Authorization", usernamePasswordToAuth("hello", "world"));
115+
r.headers.append("Authorization", usernamePasswordToAuth(username, "world"));
116116
return await fetchUnauth(r);
117117
}
118118

119+
const username = "hello";
120+
119121
describe("v2", () => {
120122
test("/v2", async () => {
121123
const response = await fetch(createRequest("GET", "/v2/", null));
@@ -497,15 +499,13 @@
497499
{
498500
configuration: "[{}]",
499501
expected: [],
500-
error:
501-
"Error parsing registries JSON: zod error: - invalid_type: Required: 0,registry\n\t- invalid_type: Required: 0,password_env\n\t- invalid_type: Required: 0,username",
502+
error: "Error parsing registries JSON: zod error: - invalid_type: Required: 0,registry",
502503
partialError: false,
503504
},
504505
{
505506
configuration: `[{ "registry": "no-url/hello-world" }]`,
506507
expected: [],
507-
error:
508-
"Error parsing registries JSON: zod error: - invalid_string: Invalid url: 0,registry\n\t- invalid_type: Required: 0,password_env\n\t- invalid_type: Required: 0,username",
508+
error: "Error parsing registries JSON: zod error: - invalid_string: Invalid url: 0,registry",
509509
partialError: false,
510510
},
511511
{
@@ -555,6 +555,18 @@
555555
partialError: false,
556556
error: "",
557557
},
558+
{
559+
configuration: `[{
560+
"registry": "https://hello.com/domain"
561+
}]`,
562+
expected: [
563+
{
564+
registry: "https://hello.com/domain",
565+
},
566+
],
567+
partialError: false,
568+
error: "",
569+
},
558570
] as const;
559571

560572
const bindings = env as Env;
@@ -601,7 +613,7 @@
601613
const client = new RegistryHTTPClient(envBindings, {
602614
registry: "https://localhost",
603615
password_env: "PASSWORD",
604-
username: "hello",
616+
username,
605617
});
606618
const res = await client.manifestExists("namespace/hello", "latest");
607619
if ("response" in res) {
@@ -625,7 +637,7 @@
625637
const client = new RegistryHTTPClient(envBindings, {
626638
registry: "https://localhost",
627639
password_env: "PASSWORD",
628-
username: "hello",
640+
username,
629641
});
630642
const res = await client.manifestExists("namespace/hello", "latest");
631643
if ("response" in res) {
@@ -654,11 +666,7 @@
654666
const tagsRes = await fetch(createRequest("GET", `/v2/hello-world-main/tags/list?n=1000`, null));
655667
const tags = (await tagsRes.json()) as TagsList;
656668
expect(tags.name).toEqual("hello-world-main");
657-
expect(tags.tags).toEqual([
658-
"hello",
659-
"hello-2",
660-
"latest",
661-
]);
669+
expect(tags.tags).toEqual(["hello", "hello-2", "latest"]);
662670

663671
const repositoryBuildUp: string[] = [];
664672
let currentPath = "/v2/_catalog?n=1";
@@ -711,11 +719,7 @@
711719
const tagsRes = await fetch(createRequest("GET", `/v2/hello-world-main/tags/list?n=1000`, null));
712720
const tags = (await tagsRes.json()) as TagsList;
713721
expect(tags.name).toEqual("hello-world-main");
714-
expect(tags.tags).toEqual([
715-
"hello",
716-
"hello-2",
717-
"latest",
718-
]);
722+
expect(tags.tags).toEqual(["hello", "hello-2", "latest"]);
719723

720724
const repositoryBuildUp: string[] = [];
721725
let currentPath = "/v2/_catalog?n=1";
@@ -1111,3 +1115,22 @@
11111115
}
11121116
});
11131117
});
1118+
1119+
test("docker.io", () => {
1120+
const t = [
1121+
["https://docker.io", true],
1122+
["https://d.docker.io", true],
1123+
["https://d.dockerr.io", false],
1124+
["https://dddocker.io", false],
1125+
["https://docker.ioo", false],
1126+
["https://d.docker.com", false],
1127+
["https://docker.com", false],
1128+
["http://docker.io", false],
1129+
] as const;
1130+
for (const testCase of t) {
1131+
const isDocker = isDockerDotIO(new URL(testCase[0]));
1132+
if (isDocker !== testCase[1]) {
1133+
throw new Error(`Expected ${testCase[1]} on ${testCase[0]} but got ${isDocker}`);
1134+
}
1135+
}
1136+
});

0 commit comments

Comments
 (0)
Please sign in to comment.