diff --git a/test/test.oci.nativecli.node.ts b/test/test.oci.nativecli.node.ts index f572a72..49057d6 100644 --- a/test/test.oci.nativecli.node.ts +++ b/test/test.oci.nativecli.node.ts @@ -48,7 +48,7 @@ async function createDockerTestRegistry(port: number): Promise { }, oci: { enabled: true, - basePath: '/oci', + basePath: '/v2', }, }; @@ -95,8 +95,7 @@ let testImageName: string; * Create HTTP server wrapper around SmartRegistry * CRITICAL: Always passes rawBody for content-addressable operations (OCI manifests/blobs) * - * Docker expects registry at /v2/ but SmartRegistry serves at /oci/v2/ - * This wrapper rewrites paths for Docker compatibility + * SmartRegistry OCI is configured with basePath '/v2' matching Docker's native /v2/ prefix. * * Also implements a simple /v2/token endpoint for Docker Bearer auth flow */ @@ -130,10 +129,7 @@ async function createHttpServer( // Log all requests for debugging console.log(`[Registry] ${req.method} ${pathname}`); - // Docker expects /v2/ but SmartRegistry serves at /oci/v2/ - if (pathname.startsWith('/v2')) { - pathname = '/oci' + pathname; - } + // basePath is /v2 which matches Docker's native /v2/ prefix — no rewrite needed // Read raw body - ALWAYS preserve exact bytes for OCI const chunks: Buffer[] = []; @@ -313,7 +309,7 @@ tap.test('Docker CLI: should verify server is responding', async () => { // Give the server a moment to fully initialize await new Promise(resolve => setTimeout(resolve, 500)); - const response = await fetch(`${registryUrl}/oci/v2/`); + const response = await fetch(`${registryUrl}/v2/`); expect(response.status).toEqual(200); console.log('OCI v2 response:', await response.json()); }); @@ -352,7 +348,7 @@ tap.test('Docker CLI: should push image to registry', async () => { }); tap.test('Docker CLI: should verify manifest in registry via API', async () => { - const response = await fetch(`${registryUrl}/oci/v2/test-image/tags/list`, { + const response = await fetch(`${registryUrl}/v2/test-image/tags/list`, { headers: { Authorization: `Bearer ${ociToken}` }, }); diff --git a/test/test.oci.ts b/test/test.oci.ts index 713e966..e7c94f1 100644 --- a/test/test.oci.ts +++ b/test/test.oci.ts @@ -24,7 +24,7 @@ tap.test('OCI: should create registry instance', async () => { tap.test('OCI: should handle version check (GET /v2/)', async () => { const response = await registry.handleRequest({ method: 'GET', - path: '/oci/v2/', + path: '/oci/', headers: {}, query: {}, }); @@ -36,7 +36,7 @@ tap.test('OCI: should handle version check (GET /v2/)', async () => { tap.test('OCI: should initiate blob upload (POST /v2/{name}/blobs/uploads/)', async () => { const response = await registry.handleRequest({ method: 'POST', - path: '/oci/v2/test-repo/blobs/uploads/', + path: '/oci/test-repo/blobs/uploads/', headers: { Authorization: `Bearer ${ociToken}`, }, @@ -53,7 +53,7 @@ tap.test('OCI: should upload blob in single PUT', async () => { const response = await registry.handleRequest({ method: 'POST', - path: '/oci/v2/test-repo/blobs/uploads/', + path: '/oci/test-repo/blobs/uploads/', headers: { Authorization: `Bearer ${ociToken}`, }, @@ -73,7 +73,7 @@ tap.test('OCI: should upload config blob', async () => { const response = await registry.handleRequest({ method: 'POST', - path: '/oci/v2/test-repo/blobs/uploads/', + path: '/oci/test-repo/blobs/uploads/', headers: { Authorization: `Bearer ${ociToken}`, }, @@ -90,7 +90,7 @@ tap.test('OCI: should upload config blob', async () => { tap.test('OCI: should check if blob exists (HEAD /v2/{name}/blobs/{digest})', async () => { const response = await registry.handleRequest({ method: 'HEAD', - path: `/oci/v2/test-repo/blobs/${testBlobDigest}`, + path: `/oci/test-repo/blobs/${testBlobDigest}`, headers: { Authorization: `Bearer ${ociToken}`, }, @@ -105,7 +105,7 @@ tap.test('OCI: should check if blob exists (HEAD /v2/{name}/blobs/{digest})', as tap.test('OCI: should retrieve blob (GET /v2/{name}/blobs/{digest})', async () => { const response = await registry.handleRequest({ method: 'GET', - path: `/oci/v2/test-repo/blobs/${testBlobDigest}`, + path: `/oci/test-repo/blobs/${testBlobDigest}`, headers: { Authorization: `Bearer ${ociToken}`, }, @@ -126,7 +126,7 @@ tap.test('OCI: should upload manifest (PUT /v2/{name}/manifests/{reference})', a const response = await registry.handleRequest({ method: 'PUT', - path: '/oci/v2/test-repo/manifests/v1.0.0', + path: '/oci/test-repo/manifests/v1.0.0', headers: { Authorization: `Bearer ${ociToken}`, 'Content-Type': 'application/vnd.oci.image.manifest.v1+json', @@ -143,7 +143,7 @@ tap.test('OCI: should upload manifest (PUT /v2/{name}/manifests/{reference})', a tap.test('OCI: should retrieve manifest by tag (GET /v2/{name}/manifests/{reference})', async () => { const response = await registry.handleRequest({ method: 'GET', - path: '/oci/v2/test-repo/manifests/v1.0.0', + path: '/oci/test-repo/manifests/v1.0.0', headers: { Authorization: `Bearer ${ociToken}`, Accept: 'application/vnd.oci.image.manifest.v1+json', @@ -163,7 +163,7 @@ tap.test('OCI: should retrieve manifest by tag (GET /v2/{name}/manifests/{refere tap.test('OCI: should retrieve manifest by digest (GET /v2/{name}/manifests/{digest})', async () => { const response = await registry.handleRequest({ method: 'GET', - path: `/oci/v2/test-repo/manifests/${testManifestDigest}`, + path: `/oci/test-repo/manifests/${testManifestDigest}`, headers: { Authorization: `Bearer ${ociToken}`, Accept: 'application/vnd.oci.image.manifest.v1+json', @@ -178,7 +178,7 @@ tap.test('OCI: should retrieve manifest by digest (GET /v2/{name}/manifests/{dig tap.test('OCI: should check if manifest exists (HEAD /v2/{name}/manifests/{reference})', async () => { const response = await registry.handleRequest({ method: 'HEAD', - path: '/oci/v2/test-repo/manifests/v1.0.0', + path: '/oci/test-repo/manifests/v1.0.0', headers: { Authorization: `Bearer ${ociToken}`, Accept: 'application/vnd.oci.image.manifest.v1+json', @@ -193,7 +193,7 @@ tap.test('OCI: should check if manifest exists (HEAD /v2/{name}/manifests/{refer tap.test('OCI: should list tags (GET /v2/{name}/tags/list)', async () => { const response = await registry.handleRequest({ method: 'GET', - path: '/oci/v2/test-repo/tags/list', + path: '/oci/test-repo/tags/list', headers: { Authorization: `Bearer ${ociToken}`, }, @@ -212,7 +212,7 @@ tap.test('OCI: should list tags (GET /v2/{name}/tags/list)', async () => { tap.test('OCI: should handle pagination for tag list', async () => { const response = await registry.handleRequest({ method: 'GET', - path: '/oci/v2/test-repo/tags/list', + path: '/oci/test-repo/tags/list', headers: { Authorization: `Bearer ${ociToken}`, }, @@ -228,7 +228,7 @@ tap.test('OCI: should handle pagination for tag list', async () => { tap.test('OCI: should return 404 for non-existent blob', async () => { const response = await registry.handleRequest({ method: 'GET', - path: '/oci/v2/test-repo/blobs/sha256:0000000000000000000000000000000000000000000000000000000000000000', + path: '/oci/test-repo/blobs/sha256:0000000000000000000000000000000000000000000000000000000000000000', headers: { Authorization: `Bearer ${ociToken}`, }, @@ -242,7 +242,7 @@ tap.test('OCI: should return 404 for non-existent blob', async () => { tap.test('OCI: should return 404 for non-existent manifest', async () => { const response = await registry.handleRequest({ method: 'GET', - path: '/oci/v2/test-repo/manifests/non-existent-tag', + path: '/oci/test-repo/manifests/non-existent-tag', headers: { Authorization: `Bearer ${ociToken}`, Accept: 'application/vnd.oci.image.manifest.v1+json', @@ -257,7 +257,7 @@ tap.test('OCI: should return 404 for non-existent manifest', async () => { tap.test('OCI: should delete manifest (DELETE /v2/{name}/manifests/{digest})', async () => { const response = await registry.handleRequest({ method: 'DELETE', - path: `/oci/v2/test-repo/manifests/${testManifestDigest}`, + path: `/oci/test-repo/manifests/${testManifestDigest}`, headers: { Authorization: `Bearer ${ociToken}`, }, @@ -270,7 +270,7 @@ tap.test('OCI: should delete manifest (DELETE /v2/{name}/manifests/{digest})', a tap.test('OCI: should delete blob (DELETE /v2/{name}/blobs/{digest})', async () => { const response = await registry.handleRequest({ method: 'DELETE', - path: `/oci/v2/test-repo/blobs/${testBlobDigest}`, + path: `/oci/test-repo/blobs/${testBlobDigest}`, headers: { Authorization: `Bearer ${ociToken}`, }, @@ -283,7 +283,7 @@ tap.test('OCI: should delete blob (DELETE /v2/{name}/blobs/{digest})', async () tap.test('OCI: should handle unauthorized requests', async () => { const response = await registry.handleRequest({ method: 'GET', - path: '/oci/v2/test-repo/manifests/v1.0.0', + path: '/oci/test-repo/manifests/v1.0.0', headers: { // No authorization header }, diff --git a/ts/oci/classes.ociregistry.ts b/ts/oci/classes.ociregistry.ts index d019df4..5dc5907 100644 --- a/ts/oci/classes.ociregistry.ts +++ b/ts/oci/classes.ociregistry.ts @@ -125,12 +125,12 @@ export class OciRegistry extends BaseRegistry { }; // Route to appropriate handler - if (path === '/v2/' || path === '/v2') { + if (path === '/' || path === '') { return this.handleVersionCheck(); } - // Manifest operations: /v2/{name}/manifests/{reference} - const manifestMatch = path.match(/^\/v2\/([^\/]+(?:\/[^\/]+)*)\/manifests\/([^\/]+)$/); + // Manifest operations: /{name}/manifests/{reference} + const manifestMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/manifests\/([^\/]+)$/); if (manifestMatch) { const [, name, reference] = manifestMatch; // Prefer rawBody for content-addressable operations to preserve exact bytes @@ -138,15 +138,15 @@ export class OciRegistry extends BaseRegistry { return this.handleManifestRequest(context.method, name, reference, token, bodyData, context.headers, actor); } - // Blob operations: /v2/{name}/blobs/{digest} - const blobMatch = path.match(/^\/v2\/([^\/]+(?:\/[^\/]+)*)\/blobs\/(sha256:[a-f0-9]{64})$/); + // Blob operations: /{name}/blobs/{digest} + const blobMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/blobs\/(sha256:[a-f0-9]{64})$/); if (blobMatch) { const [, name, digest] = blobMatch; return this.handleBlobRequest(context.method, name, digest, token, context.headers, actor); } - // Blob upload operations: /v2/{name}/blobs/uploads/ - const uploadInitMatch = path.match(/^\/v2\/([^\/]+(?:\/[^\/]+)*)\/blobs\/uploads\/?$/); + // Blob upload operations: /{name}/blobs/uploads/ + const uploadInitMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/blobs\/uploads\/?$/); if (uploadInitMatch && context.method === 'POST') { const [, name] = uploadInitMatch; // Prefer rawBody for content-addressable operations to preserve exact bytes @@ -154,22 +154,22 @@ export class OciRegistry extends BaseRegistry { return this.handleUploadInit(name, token, context.query, bodyData); } - // Blob upload operations: /v2/{name}/blobs/uploads/{uuid} - const uploadMatch = path.match(/^\/v2\/([^\/]+(?:\/[^\/]+)*)\/blobs\/uploads\/([^\/]+)$/); + // Blob upload operations: /{name}/blobs/uploads/{uuid} + const uploadMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/blobs\/uploads\/([^\/]+)$/); if (uploadMatch) { const [, name, uploadId] = uploadMatch; return this.handleUploadSession(context.method, uploadId, token, context); } - // Tags list: /v2/{name}/tags/list - const tagsMatch = path.match(/^\/v2\/([^\/]+(?:\/[^\/]+)*)\/tags\/list$/); + // Tags list: /{name}/tags/list + const tagsMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/tags\/list$/); if (tagsMatch) { const [, name] = tagsMatch; return this.handleTagsList(name, token, context.query); } - // Referrers: /v2/{name}/referrers/{digest} - const referrersMatch = path.match(/^\/v2\/([^\/]+(?:\/[^\/]+)*)\/referrers\/(sha256:[a-f0-9]{64})$/); + // Referrers: /{name}/referrers/{digest} + const referrersMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/referrers\/(sha256:[a-f0-9]{64})$/); if (referrersMatch) { const [, name, digest] = referrersMatch; return this.handleReferrers(name, digest, token, context.query); @@ -289,7 +289,7 @@ export class OciRegistry extends BaseRegistry { return { status: 201, headers: { - 'Location': `${this.basePath}/v2/${repository}/blobs/${digest}`, + 'Location': `${this.basePath}/${repository}/blobs/${digest}`, 'Docker-Content-Digest': digest, }, body: null, @@ -312,7 +312,7 @@ export class OciRegistry extends BaseRegistry { return { status: 202, headers: { - 'Location': `${this.basePath}/v2/${repository}/blobs/uploads/${uploadId}`, + 'Location': `${this.basePath}/${repository}/blobs/uploads/${uploadId}`, 'Docker-Upload-UUID': uploadId, }, body: null, @@ -527,7 +527,7 @@ export class OciRegistry extends BaseRegistry { return { status: 201, headers: { - 'Location': `${this.basePath}/v2/${repository}/manifests/${digest}`, + 'Location': `${this.basePath}/${repository}/manifests/${digest}`, 'Docker-Content-Digest': digest, }, body: null, @@ -677,7 +677,7 @@ export class OciRegistry extends BaseRegistry { return { status: 202, headers: { - 'Location': `${this.basePath}/v2/${session.repository}/blobs/uploads/${uploadId}`, + 'Location': `${this.basePath}/${session.repository}/blobs/uploads/${uploadId}`, 'Range': `0-${session.totalSize - 1}`, 'Docker-Upload-UUID': uploadId, }, @@ -719,7 +719,7 @@ export class OciRegistry extends BaseRegistry { return { status: 201, headers: { - 'Location': `${this.basePath}/v2/${session.repository}/blobs/${digest}`, + 'Location': `${this.basePath}/${session.repository}/blobs/${digest}`, 'Docker-Content-Digest': digest, }, body: null, @@ -739,7 +739,7 @@ export class OciRegistry extends BaseRegistry { return { status: 204, headers: { - 'Location': `${this.basePath}/v2/${session.repository}/blobs/uploads/${uploadId}`, + 'Location': `${this.basePath}/${session.repository}/blobs/uploads/${uploadId}`, 'Range': session.totalSize > 0 ? `0-${session.totalSize - 1}` : '0-0', 'Docker-Upload-UUID': uploadId, }, @@ -884,7 +884,7 @@ export class OciRegistry extends BaseRegistry { * Per OCI Distribution Spec, 401 responses MUST include WWW-Authenticate header. */ private createUnauthorizedResponse(repository: string, action: string): IResponse { - const realm = this.ociTokens?.realm || `${this.basePath}/v2/token`; + const realm = this.ociTokens?.realm || `${this.basePath}/token`; const service = this.ociTokens?.service || 'registry'; return { status: 401, @@ -899,7 +899,7 @@ export class OciRegistry extends BaseRegistry { * Create an unauthorized HEAD response (no body per HTTP spec). */ private createUnauthorizedHeadResponse(repository: string, action: string): IResponse { - const realm = this.ociTokens?.realm || `${this.basePath}/v2/token`; + const realm = this.ociTokens?.realm || `${this.basePath}/token`; const service = this.ociTokens?.service || 'registry'; return { status: 401, diff --git a/ts/oci/classes.ociupstream.ts b/ts/oci/classes.ociupstream.ts index 8efb3b1..e196dd4 100644 --- a/ts/oci/classes.ociupstream.ts +++ b/ts/oci/classes.ociupstream.ts @@ -24,13 +24,18 @@ export class OciUpstream extends BaseUpstream { /** Local registry base path for URL building */ private readonly localBasePath: string; + /** API prefix for outbound OCI requests (default: /v2) */ + private readonly apiPrefix: string; + constructor( config: IProtocolUpstreamConfig, localBasePath: string = '/oci', logger?: plugins.smartlog.Smartlog, + apiPrefix: string = '/v2', ) { super(config, logger); this.localBasePath = localBasePath; + this.apiPrefix = apiPrefix; } /** @@ -44,7 +49,7 @@ export class OciUpstream extends BaseUpstream { protocol: 'oci', resource: repository, resourceType: 'manifest', - path: `/v2/${repository}/manifests/${reference}`, + path: `${this.apiPrefix}/${repository}/manifests/${reference}`, method: 'GET', headers: { 'accept': [ @@ -88,7 +93,7 @@ export class OciUpstream extends BaseUpstream { protocol: 'oci', resource: repository, resourceType: 'manifest', - path: `/v2/${repository}/manifests/${reference}`, + path: `${this.apiPrefix}/${repository}/manifests/${reference}`, method: 'HEAD', headers: { 'accept': [ @@ -127,7 +132,7 @@ export class OciUpstream extends BaseUpstream { protocol: 'oci', resource: repository, resourceType: 'blob', - path: `/v2/${repository}/blobs/${digest}`, + path: `${this.apiPrefix}/${repository}/blobs/${digest}`, method: 'GET', headers: { 'accept': 'application/octet-stream', @@ -155,7 +160,7 @@ export class OciUpstream extends BaseUpstream { protocol: 'oci', resource: repository, resourceType: 'blob', - path: `/v2/${repository}/blobs/${digest}`, + path: `${this.apiPrefix}/${repository}/blobs/${digest}`, method: 'HEAD', headers: {}, query: {}, @@ -189,7 +194,7 @@ export class OciUpstream extends BaseUpstream { protocol: 'oci', resource: repository, resourceType: 'tags', - path: `/v2/${repository}/tags/list`, + path: `${this.apiPrefix}/${repository}/tags/list`, method: 'GET', headers: { 'accept': 'application/json', @@ -215,7 +220,8 @@ export class OciUpstream extends BaseUpstream { /** * Override URL building for OCI-specific handling. - * OCI registries use /v2/ prefix and may require special handling for Docker Hub. + * OCI registries use a configurable API prefix (default /v2/) and may require + * special handling for Docker Hub. */ protected buildUpstreamUrl( upstream: IUpstreamRegistryConfig, @@ -228,16 +234,20 @@ export class OciUpstream extends BaseUpstream { baseUrl = baseUrl.slice(0, -1); } + // Use per-upstream apiPrefix if configured, otherwise use the instance default + const prefix = upstream.apiPrefix || this.apiPrefix; + // Handle Docker Hub special case // Docker Hub uses registry-1.docker.io but library images need special handling if (baseUrl.includes('docker.io') || baseUrl.includes('registry-1.docker.io')) { // For library images (e.g., "nginx" -> "library/nginx") - const pathParts = context.path.match(/^\/v2\/([^\/]+)\/(.+)$/); + const escapedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const pathParts = context.path.match(new RegExp(`^${escapedPrefix}\\/([^\\/]+)\\/(.+)$`)); if (pathParts) { const [, repository, rest] = pathParts; // If repository doesn't contain a slash, it's a library image if (!repository.includes('/')) { - return `${baseUrl}/v2/library/${repository}/${rest}`; + return `${baseUrl}${prefix}/library/${repository}/${rest}`; } } } diff --git a/ts/upstream/interfaces.upstream.ts b/ts/upstream/interfaces.upstream.ts index 1bfe342..a91027f 100644 --- a/ts/upstream/interfaces.upstream.ts +++ b/ts/upstream/interfaces.upstream.ts @@ -86,6 +86,8 @@ export interface IUpstreamRegistryConfig { cache?: Partial; /** Resilience configuration overrides */ resilience?: Partial; + /** API path prefix for OCI registries (default: /v2). Useful for registries behind reverse proxies. */ + apiPrefix?: string; } /**