diff --git a/README.md b/README.md index 69d653d..94cdbb9 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,7 @@ Xget 支持多个容器注册表,使用 `cr/[容器注册表前缀]` 格式: | 容器注册表 | 容器注册表前缀 | 原始链接格式 | 加速链接格式 | |----------|------|--------------|--------------| +| Docker Hub | `docker` | `https://registry-1.docker.io/...` | `https://xget.xi-xu.me/cr/docker/...` | | Quay.io | `quay` | `https://quay.io/...` | `https://xget.xi-xu.me/cr/quay/...` | | 谷歌 | `gcr` | `https://gcr.io/...` | `https://xget.xi-xu.me/cr/gcr/...` | | 微软 | `mcr` | `https://mcr.microsoft.com/...` | `https://xget.xi-xu.me/cr/mcr/...` | @@ -219,6 +220,18 @@ Xget 支持多个容器注册表,使用 `cr/[容器注册表前缀]` 格式: | Gitpod | `gitpod` | `https://registry.gitpod.io/...` | `https://xget.xi-xu.me/cr/gitpod/...` | ```url +# Docker Hub 原始链接(library 镜像) +https://registry-1.docker.io/v2/library/nginx/manifests/latest + +# 转换后(添加 cr/docker 前缀,自动处理 library 前缀) +https://xget.xi-xu.me/cr/docker/v2/nginx/manifests/latest + +# Docker Hub 原始链接(用户镜像) +https://registry-1.docker.io/v2/nginxinc/nginx-unprivileged/manifests/latest + +# 转换后(添加 cr/docker 前缀) +https://xget.xi-xu.me/cr/docker/v2/nginxinc/nginx-unprivileged/manifests/latest + # GitHub 容器容器注册表原始链接 https://ghcr.io/v2/nginxinc/nginx-unprivileged/manifests/latest @@ -502,6 +515,14 @@ docker info | grep -A 10 "Registry Mirrors" #### 直接拉取镜像 ```bash +# 拉取 Docker Hub 官方镜像(library 镜像) +docker pull xget.xi-xu.me/cr/docker/v2/nginx:latest +docker pull xget.xi-xu.me/cr/docker/v2/alpine:latest +docker pull xget.xi-xu.me/cr/docker/v2/ubuntu:22.04 + +# 拉取 Docker Hub 用户镜像 +docker pull xget.xi-xu.me/cr/docker/v2/nginxinc/nginx-unprivileged:latest + # 拉取 GitHub Container Registry 镜像 docker pull xget.xi-xu.me/cr/ghcr/nginxinc/nginx-unprivileged:latest diff --git a/src/config/docker.js b/src/config/docker.js new file mode 100644 index 0000000..86300f2 --- /dev/null +++ b/src/config/docker.js @@ -0,0 +1,110 @@ +/** + * Docker Hub specific configuration and utilities + */ + +export const DOCKER_HUB_REGISTRY = 'https://registry-1.docker.io'; +export const DOCKER_HUB_AUTH = 'https://auth.docker.io'; + +/** + * Parses Docker WWW-Authenticate header + * @param {string} authenticateStr - The WWW-Authenticate header value + * @returns {{realm: string, service: string}} Parsed authentication info + */ +export function parseDockerAuthenticate(authenticateStr) { + // sample: Bearer realm="https://auth.docker.io/token",service="registry.docker.io" + const re = /(?<=\=")(?:\\.|[^"\\])*(?=")/g; + const matches = authenticateStr.match(re); + if (matches == null || matches.length < 2) { + throw new Error(`invalid Www-Authenticate Header: ${authenticateStr}`); + } + return { + realm: matches[0], + service: matches[1] + }; +} + +/** + * Fetches authentication token from Docker registry + * @param {{realm: string, service: string}} wwwAuthenticate - Authentication info + * @param {string} scope - The scope for the token + * @param {string} authorization - Authorization header value + * @returns {Promise} Token response + */ +export async function fetchDockerToken(wwwAuthenticate, scope, authorization) { + const url = new URL(wwwAuthenticate.realm); + if (wwwAuthenticate.service.length) { + url.searchParams.set('service', wwwAuthenticate.service); + } + if (scope) { + url.searchParams.set('scope', scope); + } + const headers = new Headers(); + if (authorization) { + headers.set('Authorization', authorization); + } + return await fetch(url, { method: 'GET', headers: headers }); +} + +/** + * Creates unauthorized response for Docker registry + * @param {URL} url - Request URL + * @returns {Response} Unauthorized response + */ +export function createDockerUnauthorizedResponse(url) { + const headers = new Headers(); + headers.set( + 'WWW-Authenticate', + `Bearer realm="https://${url.hostname}/v2/auth",service="cloudflare-docker-proxy"` + ); + return new Response(JSON.stringify({ message: 'UNAUTHORIZED' }), { + status: 401, + headers: headers + }); +} + +/** + * Handles Docker Hub library image path transformation + * Docker Hub library images need special handling - they need "library/" prefix + * @param {string} path - Original path + * @returns {string} Transformed path + */ +export function transformDockerHubPath(path) { + // Handle Docker Hub library images + // Example: /v2/busybox/manifests/latest => /v2/library/busybox/manifests/latest + const pathParts = path.split('/'); + if (pathParts.length >= 4 && pathParts[1] === 'v2' && !pathParts[2].includes('/')) { + // Check if this is a library image (no namespace) + if (pathParts.length === 5 && (pathParts[3] === 'manifests' || pathParts[3] === 'blobs')) { + pathParts.splice(2, 0, 'library'); + return pathParts.join('/'); + } + } + return path; +} + +/** + * Handles Docker Hub scope transformation for authentication + * @param {string} scope - Original scope + * @returns {string} Transformed scope + */ +export function transformDockerHubScope(scope) { + if (!scope) return scope; + + // autocomplete repo part into scope for DockerHub library images + // Example: repository:busybox:pull => repository:library/busybox:pull + const scopeParts = scope.split(':'); + if (scopeParts.length === 3 && !scopeParts[1].includes('/')) { + scopeParts[1] = 'library/' + scopeParts[1]; + return scopeParts.join(':'); + } + return scope; +} + +/** + * Checks if the upstream is Docker Hub + * @param {string} upstream - Upstream URL + * @returns {boolean} True if Docker Hub + */ +export function isDockerHub(upstream) { + return upstream === DOCKER_HUB_REGISTRY; +} \ No newline at end of file diff --git a/src/config/platforms.js b/src/config/platforms.js index 2ec3dd5..1a3046a 100644 --- a/src/config/platforms.js +++ b/src/config/platforms.js @@ -13,6 +13,7 @@ export const PLATFORMS = { 'conda-community': 'https://conda.anaconda.org', // Container Registries + 'cr-docker': 'https://registry-1.docker.io', 'cr-quay': 'https://quay.io', 'cr-gcr': 'https://gcr.io', 'cr-mcr': 'https://mcr.microsoft.com', diff --git a/src/index.js b/src/index.js index 7617b32..40fa117 100644 --- a/src/index.js +++ b/src/index.js @@ -141,59 +141,7 @@ function addSecurityHeaders(headers) { return headers; } -/** - * Parses Docker WWW-Authenticate header - * @param {string} authenticateStr - The WWW-Authenticate header value - * @returns {{realm: string, service: string}} Parsed authentication info - */ -function parseAuthenticate(authenticateStr) { - // sample: Bearer realm="https://auth.ipv6.docker.com/token",service="registry.docker.io" - const re = /(?<=\=")(?:\\.|[^"\\])*(?=")/g; - const matches = authenticateStr.match(re); - if (matches == null || matches.length < 2) { - throw new Error(`invalid Www-Authenticate Header: ${authenticateStr}`); - } - return { - realm: matches[0], - service: matches[1] - }; -} -/** - * Fetches authentication token from container registry - * @param {{realm: string, service: string}} wwwAuthenticate - Authentication info - * @param {string} scope - The scope for the token - * @param {string} authorization - Authorization header value - * @returns {Promise} Token response - */ -async function fetchToken(wwwAuthenticate, scope, authorization) { - const url = new URL(wwwAuthenticate.realm); - if (wwwAuthenticate.service.length) { - url.searchParams.set('service', wwwAuthenticate.service); - } - if (scope) { - url.searchParams.set('scope', scope); - } - const headers = new Headers(); - if (authorization) { - headers.set('Authorization', authorization); - } - return await fetch(url, { method: 'GET', headers: headers }); -} - -/** - * Creates unauthorized response for container registry - * @param {URL} url - Request URL - * @returns {Response} Unauthorized response - */ -function responseUnauthorized(url) { - const headers = new Headers(); - headers.set('WWW-Authenticate', `Bearer realm="https://${url.hostname}/v2/auth",service="Xget"`); - return new Response(JSON.stringify({ message: 'UNAUTHORIZED' }), { - status: 401, - headers: headers - }); -} /** * Handles incoming requests with caching, retries, and security measures @@ -278,6 +226,11 @@ async function handleRequest(request, env, ctx) { let finalTargetPath; if (platform.startsWith('cr-')) { finalTargetPath = `/v2${targetPath}`; + + // Handle Docker Hub library image path transformation + if (platform === 'cr-docker') { + finalTargetPath = transformDockerHubPath(finalTargetPath); + } } else { finalTargetPath = targetPath; } @@ -287,7 +240,8 @@ async function handleRequest(request, env, ctx) { // Handle Docker authentication if (isDocker && url.pathname === '/v2/auth') { - const newUrl = new URL(CONFIG.PLATFORMS[platform] + '/v2/'); + const upstream = CONFIG.PLATFORMS[platform]; + const newUrl = new URL(upstream + '/v2/'); const resp = await fetch(newUrl.toString(), { method: 'GET', redirect: 'follow' @@ -299,9 +253,15 @@ async function handleRequest(request, env, ctx) { if (authenticateStr === null) { return resp; } - const wwwAuthenticate = parseAuthenticate(authenticateStr); + const wwwAuthenticate = parseDockerAuthenticate(authenticateStr); let scope = url.searchParams.get('scope'); - return await fetchToken(wwwAuthenticate, scope || '', authorization || ''); + + // Handle Docker Hub library image scope transformation + if (isDockerHub(upstream) && scope) { + scope = transformDockerHubScope(scope); + } + + return await fetchDockerToken(wwwAuthenticate, scope || '', authorization || ''); } // Check if this is a Git operation @@ -427,13 +387,14 @@ async function handleRequest(request, env, ctx) { const authenticateStr = response.headers.get('WWW-Authenticate'); if (authenticateStr) { try { - const wwwAuthenticate = parseAuthenticate(authenticateStr); + const wwwAuthenticate = parseDockerAuthenticate(authenticateStr); + const upstream = CONFIG.PLATFORMS[platform]; // Infer scope from the request path for container registry requests let scope = ''; const pathParts = url.pathname.split('/'); if (pathParts.length >= 4 && pathParts[1] === 'v2') { - // Extract repository name from path like /v2/cr/ghcr/nginxinc/nginx-unprivileged/manifests/latest + // Extract repository name from path like /v2/cr/docker/nginxinc/nginx-unprivileged/manifests/latest // Remove /v2 and platform prefix to get the repo path const repoPath = pathParts.slice(4).join('/'); // Skip /v2/cr/[registry] const repoParts = repoPath.split('/'); @@ -441,12 +402,17 @@ async function handleRequest(request, env, ctx) { const repoName = repoParts.slice(0, -2).join('/'); // Remove /manifests/tag or /blobs/sha if (repoName) { scope = `repository:${repoName}:pull`; + + // Handle Docker Hub library image scope transformation + if (isDockerHub(upstream)) { + scope = transformDockerHubScope(scope); + } } } } // Try to get a token for public access (without authorization) - const tokenResponse = await fetchToken(wwwAuthenticate, scope || '', ''); + const tokenResponse = await fetchDockerToken(wwwAuthenticate, scope || '', ''); if (tokenResponse.ok) { const tokenData = await tokenResponse.json(); if (tokenData.token) { @@ -473,7 +439,7 @@ async function handleRequest(request, env, ctx) { // If token fetch failed or didn't work, return the unauthorized response // Only return this if we truly can't access the resource - return responseUnauthorized(url); + return createDockerUnauthorizedResponse(url); } // Don't retry on client errors (4xx) - these won't improve with retries diff --git a/test/docker-hub.test.js b/test/docker-hub.test.js new file mode 100644 index 0000000..73d4507 --- /dev/null +++ b/test/docker-hub.test.js @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; +import { + DOCKER_HUB_REGISTRY, + isDockerHub, + parseDockerAuthenticate, + transformDockerHubPath, + transformDockerHubScope +} from '../src/config/docker.js'; + +describe('Docker Hub Support', () => { + describe('parseDockerAuthenticate', () => { + it('should parse Docker Hub WWW-Authenticate header correctly', () => { + const authenticateStr = 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io"'; + const result = parseDockerAuthenticate(authenticateStr); + + expect(result.realm).toBe('https://auth.docker.io/token'); + expect(result.service).toBe('registry.docker.io'); + }); + + it('should throw error for invalid WWW-Authenticate header', () => { + const invalidHeader = 'Bearer invalid-header'; + + expect(() => parseDockerAuthenticate(invalidHeader)).toThrow('invalid Www-Authenticate Header'); + }); + }); + + describe('transformDockerHubPath', () => { + it('should add library prefix for Docker Hub library images', () => { + const path = '/v2/nginx/manifests/latest'; + const result = transformDockerHubPath(path); + + expect(result).toBe('/v2/library/nginx/manifests/latest'); + }); + + it('should add library prefix for blob requests', () => { + const path = '/v2/alpine/blobs/sha256:abc123'; + const result = transformDockerHubPath(path); + + expect(result).toBe('/v2/library/alpine/blobs/sha256:abc123'); + }); + + it('should not modify paths with existing namespace', () => { + const path = '/v2/nginxinc/nginx-unprivileged/manifests/latest'; + const result = transformDockerHubPath(path); + + expect(result).toBe('/v2/nginxinc/nginx-unprivileged/manifests/latest'); + }); + + it('should not modify non-Docker API paths', () => { + const path = '/some/other/path'; + const result = transformDockerHubPath(path); + + expect(result).toBe('/some/other/path'); + }); + }); + + describe('transformDockerHubScope', () => { + it('should add library prefix to scope for library images', () => { + const scope = 'repository:nginx:pull'; + const result = transformDockerHubScope(scope); + + expect(result).toBe('repository:library/nginx:pull'); + }); + + it('should not modify scope with existing namespace', () => { + const scope = 'repository:nginxinc/nginx-unprivileged:pull'; + const result = transformDockerHubScope(scope); + + expect(result).toBe('repository:nginxinc/nginx-unprivileged:pull'); + }); + + it('should handle empty scope', () => { + const result = transformDockerHubScope(''); + expect(result).toBe(''); + }); + + it('should handle null scope', () => { + const result = transformDockerHubScope(null); + expect(result).toBe(null); + }); + }); + + describe('isDockerHub', () => { + it('should identify Docker Hub registry URL', () => { + expect(isDockerHub(DOCKER_HUB_REGISTRY)).toBe(true); + expect(isDockerHub('https://registry-1.docker.io')).toBe(true); + }); + + it('should not identify other registries as Docker Hub', () => { + expect(isDockerHub('https://ghcr.io')).toBe(false); + expect(isDockerHub('https://quay.io')).toBe(false); + expect(isDockerHub('https://gcr.io')).toBe(false); + }); + }); +}); \ No newline at end of file