import "server-only"; import { createReadStream } from "node:fs"; import { stat } from "node:fs/promises"; import { Readable } from "node:stream"; import { appendUpload, cancelUpload, deleteManifest, deleteTag, findBlob, findManifest, findUpload, finishUpload, isDigest, isImagePath, listReferrers, listTags, MAX_MANIFEST_BYTES, MEDIA_TYPES, mountBlob, putManifest, RegistryError, startUpload, TAG_RE, unlinkBlob, type Upload, } from "./registry"; import { canWrite, findRepo, repoAccess, type RepoRecord } from "./repos"; import { basicAuthToken, tokenCovers } from "./tokens"; // The OCI distribution API (Docker Registry HTTP API v2) under /v2. Clients log in with HTTP Basic // auth, an API token as the password, like git over HTTPS: `docker login -u `. const API_HEADERS = { "Docker-Distribution-API-Version": "registry/2.0" }; function reply(status: number, headers: Record = {}, body: BodyInit | null = null) { return new Response(body, { status, headers: { ...API_HEADERS, ...headers } }); } function json(status: number, data: unknown, headers: Record = {}) { return reply(status, { "Content-Type": "application/json", ...headers }, JSON.stringify(data)); } function error(status: number, code: string, message: string, headers: Record = {}) { return json(status, { errors: [{ code, message, detail: null }] }, headers); } const unauthorized = (message: string) => error(401, "UNAUTHORIZED", message, { "WWW-Authenticate": 'Basic realm="Vectrum"' }); type Target = { repo: RepoRecord; image: string; name: string }; type Credentials = Awaited>; /** * Resolves an image name (/[/]) and checks that the request may pull from or * push to it. Anonymous users can pull images of public repositories; everything else needs a * token that covers the repository, and pushing needs write access to it. */ async function authorize(creds: Credentials, name: string, need: "pull" | "push"): Promise { if (creds === "invalid") return unauthorized("Invalid or expired token."); const [owner, repoName, ...rest] = name.split("/"); const image = rest.join("/"); if (!repoName || !isImagePath(name)) return error(400, "NAME_INVALID", `Invalid image name ${name}. Names are lowercase.`); const repo = await findRepo(owner, repoName); if (!creds) { if (repo && need === "pull" && repo.visibility === "public") return { repo, image, name }; // Ask for credentials; don't reveal whether private repositories exist. return unauthorized("Authentication required. Log in with an API token as the password."); } const access = repo ? await repoAccess(repo, creds.user.id) : null; if (!repo || !access) { return error(404, "NAME_UNKNOWN", `No repository ${owner}/${repoName}. Images are pushed to an existing repository.`); } const pushing = need === "push"; if (pushing && creds.token.permission !== "write") { return error(403, "DENIED", "This token is read-only. Create a read & write token to push."); } if (!tokenCovers(creds.token, repo.id, pushing)) { return error(403, "DENIED", "This token does not grant access to this repository."); } if (pushing && !canWrite(access)) return error(403, "DENIED", `Permission to ${repo.owner}/${repo.name} denied.`); return { repo, image, name }; } const ROUTES = [ { kind: "uploads", re: /^\/(.+)\/blobs\/uploads\/?$/ }, { kind: "upload", re: /^\/(.+)\/blobs\/uploads\/([^/]+)$/ }, { kind: "blob", re: /^\/(.+)\/blobs\/([^/]+)$/ }, { kind: "manifest", re: /^\/(.+)\/manifests\/([^/]+)$/ }, { kind: "tags", re: /^\/(.+)\/tags\/list$/ }, { kind: "referrers", re: /^\/(.+)\/referrers\/([^/]+)$/ }, ] as const; type Kind = (typeof ROUTES)[number]["kind"]; const PUSH_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); const ALLOWED: Record = { uploads: ["POST"], upload: ["GET", "PATCH", "PUT", "DELETE"], blob: ["GET", "HEAD", "DELETE"], manifest: ["GET", "HEAD", "PUT", "DELETE"], tags: ["GET"], referrers: ["GET"], }; /** Handles every request under /v2. */ export async function handleRegistry(request: Request) { const url = new URL(request.url); const rest = url.pathname.replace(/^\/v2/, ""); const creds = await basicAuthToken(request); if (rest === "" || rest === "/") { if (request.method !== "GET" && request.method !== "HEAD") return error(405, "UNSUPPORTED", "Method not allowed."); // `docker login` checks its credentials here, so anonymous requests are always challenged. if (!creds) return unauthorized("Authentication required. Log in with an API token as the password."); if (creds === "invalid") return unauthorized("Invalid or expired token."); return json(200, {}); } for (const { kind, re } of ROUTES) { const match = re.exec(rest); if (!match) continue; const [name, param = ""] = match.slice(1).map(decodeURIComponent); if (!ALLOWED[kind].includes(request.method)) return error(405, "UNSUPPORTED", "Method not allowed."); const target = await authorize(creds, name, PUSH_METHODS.has(request.method) ? "push" : "pull"); if (target instanceof Response) return target; try { switch (kind) { case "uploads": return await startBlobUpload(request, url, target, creds); case "upload": return await continueBlobUpload(request, url, target, param); case "blob": return await blob(request, target, param); case "manifest": return await manifest(request, target, param); case "tags": return tags(url, target); case "referrers": return referrers(url, target, param); } } catch (e) { if (e instanceof RegistryError) return error(e.status, e.code, e.message); throw e; } } return error(404, "NAME_UNKNOWN", "Not found."); } // --------------------------------------------------------------------------- // Blobs async function blob(request: Request, { repo }: Target, digest: string) { if (!isDigest(digest)) return error(400, "DIGEST_INVALID", "Invalid digest."); if (request.method === "DELETE") { return unlinkBlob(repo.id, digest) ? reply(202) : error(404, "BLOB_UNKNOWN", "Blob not found."); } const found = findBlob(repo.id, digest); const size = found && (await stat(found.path).catch(() => null))?.size; if (!found || size == null) return error(404, "BLOB_UNKNOWN", "Blob not found."); const headers: Record = { "Content-Type": "application/octet-stream", "Docker-Content-Digest": digest, "Accept-Ranges": "bytes", // Content-addressed, so it never changes. "Cache-Control": "public, max-age=31536000, immutable", }; let start = 0; let end = size - 1; const range = request.headers.get("range"); if (range) { const m = /^bytes=(\d*)-(\d*)$/.exec(range.trim()); if (m && (m[1] || m[2])) { start = m[1] ? Number(m[1]) : Math.max(0, size - Number(m[2])); end = m[1] && m[2] ? Math.min(Number(m[2]), size - 1) : size - 1; } if (!m || start > end || start >= size) return reply(416, { ...headers, "Content-Range": `bytes */${size}` }); headers["Content-Range"] = `bytes ${start}-${end}/${size}`; } headers["Content-Length"] = String(end - start + 1); const status = range ? 206 : 200; if (request.method === "HEAD" || size === 0) return reply(status, headers); const stream = Readable.toWeb(createReadStream(found.path, { start, end })) as ReadableStream; return reply(status, headers, stream); } const uploadHeaders = ({ name }: Target, upload: Upload, size: number) => ({ Location: `/v2/${name}/blobs/uploads/${upload.id}`, Range: `0-${Math.max(0, size - 1)}`, "Docker-Upload-UUID": upload.id, }); const blobCreated = ({ name }: Target, digest: string) => reply(201, { Location: `/v2/${name}/blobs/${digest}`, "Docker-Content-Digest": digest }); /** POST /v2//blobs/uploads/: mounts a blob, takes a whole blob, or starts a chunked upload. */ async function startBlobUpload(request: Request, url: URL, target: Target, creds: Credentials) { const mount = url.searchParams.get("mount"); const from = url.searchParams.get("from"); if (mount && from && isDigest(mount)) { const source = await authorize(creds, from, "pull"); if (!(source instanceof Response) && mountBlob(target.repo.id, source.repo.id, mount)) return blobCreated(target, mount); // Not mountable: the client uploads the blob instead. } const upload = startUpload(target.repo.id); const digest = url.searchParams.get("digest"); if (digest) { if (!isDigest(digest)) { cancelUpload(upload); return error(400, "DIGEST_INVALID", "Invalid digest."); } await appendOrCancel(upload, request.body); await finishUpload(upload, digest); return blobCreated(target, digest); } return reply(202, { ...uploadHeaders(target, upload, 0), "Content-Length": "0" }); } async function appendOrCancel(upload: Upload, body: ReadableStream | null) { try { return await appendUpload(upload, body); } catch (e) { cancelUpload(upload); throw e; } } /** /v2//blobs/uploads/: status, a chunk, the final chunk with the digest, or cancel. */ async function continueBlobUpload(request: Request, url: URL, target: Target, id: string) { const upload = findUpload(target.repo.id, id); if (!upload) return error(404, "BLOB_UPLOAD_UNKNOWN", "Upload not found. It may have expired."); switch (request.method) { case "GET": return reply(204, uploadHeaders(target, upload, upload.size)); case "DELETE": cancelUpload(upload); return reply(204); case "PATCH": { // Chunks must arrive in order: "Content-Range: -" has to continue where the upload ends. const range = /^(?:bytes )?(\d+)-(\d+)/.exec(request.headers.get("content-range") ?? ""); if (range && Number(range[1]) !== upload.size) { return error(416, "BLOB_UPLOAD_INVALID", "Chunk out of order.", uploadHeaders(target, upload, upload.size)); } const size = await appendUpload(upload, request.body); return reply(202, { ...uploadHeaders(target, upload, size), "Content-Length": "0" }); } default: { const digest = url.searchParams.get("digest"); if (!digest || !isDigest(digest)) return error(400, "DIGEST_INVALID", "A valid digest is required to finish an upload."); const size = await appendUpload(upload, request.body); await finishUpload({ ...upload, size }, digest); return blobCreated(target, digest); } } } // --------------------------------------------------------------------------- // Manifests, tags, referrers /** Reads a request body, refusing more than `limit` bytes. */ async function readBody(request: Request, limit: number) { if (Number(request.headers.get("content-length")) > limit) return null; const chunks: Uint8Array[] = []; let length = 0; if (!request.body) return Buffer.alloc(0); for await (const chunk of Readable.fromWeb(request.body as never) as AsyncIterable) { length += chunk.byteLength; if (length > limit) return null; chunks.push(chunk); } return Buffer.concat(chunks); } async function manifest(request: Request, target: Target, reference: string) { const { repo, image, name } = target; const valid = isDigest(reference) || TAG_RE.test(reference); if (request.method === "PUT") { if (!valid) return error(400, "TAG_INVALID", "Invalid tag."); const content = await readBody(request, MAX_MANIFEST_BYTES); if (!content) return error(413, "SIZE_INVALID", "Manifests can be at most 4 MB."); const { digest, subject } = putManifest(repo.id, image, reference, request.headers.get("content-type"), content); return reply(201, { Location: `/v2/${name}/manifests/${digest}`, "Docker-Content-Digest": digest, ...(subject ? { "OCI-Subject": subject } : {}), }); } if (request.method === "DELETE") { // A digest deletes the manifest and its tags; a tag only removes the tag. const deleted = isDigest(reference) ? deleteManifest(repo.id, image, reference) : valid && deleteTag(repo.id, image, reference); return deleted ? reply(202) : error(404, "MANIFEST_UNKNOWN", "Manifest not found."); } const found = valid ? findManifest(repo.id, image, reference) : null; if (!found) return error(404, "MANIFEST_UNKNOWN", `Manifest ${reference} not found.`); const headers = { "Content-Type": found.mediaType, "Content-Length": String(found.content.byteLength), "Docker-Content-Digest": found.digest, }; return reply(200, headers, request.method === "HEAD" ? null : Buffer.from(found.content)); } /** GET /v2//tags/list?n=&last= */ function tags(url: URL, { repo, image, name }: Target) { const nParam = url.searchParams.get("n"); const n = nParam === null ? undefined : Math.max(0, Math.floor(Number(nParam)) || 0); const last = url.searchParams.get("last") ?? undefined; const list = n === 0 ? [] : listTags(repo.id, image, { n, last }); const headers: Record = {}; if (n && list.length === n) { const next = new URLSearchParams({ n: String(n), last: list[list.length - 1] }); headers.Link = `; rel="next"`; } return json(200, { name, tags: list }, headers); } /** GET /v2//referrers/?artifactType= */ function referrers(url: URL, { repo, image }: Target, digest: string) { if (!isDigest(digest)) return error(400, "DIGEST_INVALID", "Invalid digest."); const artifactType = url.searchParams.get("artifactType"); const index = { schemaVersion: 2, mediaType: MEDIA_TYPES.ociIndex, manifests: listReferrers(repo.id, image, digest, artifactType) }; return json(200, index, { "Content-Type": MEDIA_TYPES.ociIndex, ...(artifactType ? { "OCI-Filters-Applied": "artifactType" } : {}), }); }