Add Docker Hub support with path and scope handling

Introduces Docker Hub-specific configuration and utilities, including path and scope transformation for library images. Updates the main logic to handle Docker Hub authentication and path rewriting. Adds tests for Docker Hub path, scope, and authentication header parsing. Updates documentation to reflect Docker Hub support and usage examples.
This commit is contained in:
xixu-me committed 2025-07-28 17:04:07 +08:00
1 parent 2680618594
commit eef204ab49
5 files changed
+252 -59

No files matched your search

+21
View File
@@ -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
+110
View File
@@ -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<Response>} 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;
}
+1
View File
@@ -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',
+25 -59
View File
@@ -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<Response>} 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
+95
View File
@@ -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);
});
});
});