From e6dd73620e177cae6dc62866c731952d88159325 Mon Sep 17 00:00:00 2001 From: Pascal GUINET Date: Fri, 20 Feb 2026 08:55:43 +0100 Subject: [PATCH 1/2] Add Harbor fallback for catalog and search endpoints Harbor denies access to the V2 _catalog endpoint for robot accounts (scope registry:catalog:* returns an empty JWT). This adds automatic Harbor detection and falls back to the native Harbor project API (/api/v2.0/projects/{name}/repositories) for both catalog listing and image search. - Detect Harbor via WWW-Authenticate header + /api/v2.0/ping (cached 5min) - List repositories through Harbor project API with pagination - Search repositories using Harbor's q=name=~ filter - Transparent fallback: no configuration change required Fixes #360 Co-Authored-By: Claude Opus 4.6 --- src/lib/server/docker.ts | 196 +++++++++++++++++++++ src/routes/api/registry/catalog/+server.ts | 44 ++++- src/routes/api/registry/search/+server.ts | 8 +- 3 files changed, 246 insertions(+), 2 deletions(-) diff --git a/src/lib/server/docker.ts b/src/lib/server/docker.ts index 261ba91..63ab1cf 100644 --- a/src/lib/server/docker.ts +++ b/src/lib/server/docker.ts @@ -2462,6 +2462,202 @@ export async function getRegistryAuth( return { baseUrl, orgPath: parsed.path, authHeader }; } +// --- Harbor fallback pour le catalog et la recherche d'images --- +// Harbor interdit l'accès au endpoint V2 _catalog pour les robots. +// On détecte Harbor et on utilise l'API projet native en fallback. + +/** Cache de détection Harbor par host (TTL 5 min) */ +const harborDetectionCache = new Map(); +const HARBOR_CACHE_TTL = 5 * 60 * 1000; + +export interface HarborCatalogResult { + repositories: string[]; + /** Curseur de pagination : "harbor:" ou null si dernière page */ + nextLast: string | null; +} + +/** + * Détecte si un registry est une instance Harbor. + * Vérifie service="harbor-registry" dans le header WWW-Authenticate de /v2/, + * puis confirme via /api/v2.0/ping. Résultat mis en cache 5 min par host. + */ +export async function isHarborRegistry(registryUrl: string): Promise { + const parsed = parseRegistryUrl(registryUrl); + const host = parsed.host; + + const cached = harborDetectionCache.get(host); + if (cached && Date.now() - cached.ts < HARBOR_CACHE_TTL) { + return cached.isHarbor; + } + + let isHarbor = false; + try { + const baseUrl = `https://${host}`; + + // Étape 1 : vérifier le header WWW-Authenticate de /v2/ + const challengeResp = await fetch(`${baseUrl}/v2/`, { + method: 'GET', + headers: { 'User-Agent': 'Dockhand/1.0' } + }); + const wwwAuth = challengeResp.headers.get('WWW-Authenticate') || ''; + if (wwwAuth.toLowerCase().includes('service="harbor-registry"')) { + // Étape 2 : confirmer via /api/v2.0/ping + const pingResp = await fetch(`${baseUrl}/api/v2.0/ping`, { + method: 'GET', + headers: { 'User-Agent': 'Dockhand/1.0' } + }); + if (pingResp.ok) { + const body = await pingResp.text(); + if (body.includes('Pong')) { + isHarbor = true; + } + } + } + } catch { + // En cas d'erreur réseau, on considère que ce n'est pas Harbor + } + + harborDetectionCache.set(host, { isHarbor, ts: Date.now() }); + return isHarbor; +} + +/** + * Construit le header Basic auth pour l'API Harbor à partir d'un objet registry. + */ +function getHarborBasicAuth(registry: { username?: string | null; password?: string | null }): string | null { + if (registry.username && registry.password) { + return `Basic ${Buffer.from(`${registry.username}:${registry.password}`).toString('base64')}`; + } + return null; +} + +/** + * Liste les repositories via l'API projet Harbor. + * Si orgPath est défini → un seul projet. Sinon → énumère tous les projets accessibles. + * @param page - numéro de page (1-based) + * @param pageSize - nombre de résultats par page + */ +export async function harborListRepositories( + registry: { url: string; username?: string | null; password?: string | null }, + orgPath: string, + page: number = 1, + pageSize: number = 100 +): Promise { + const parsed = parseRegistryUrl(registry.url); + const baseUrl = `https://${parsed.host}/api/v2.0`; + const authHeader = getHarborBasicAuth(registry); + + const headers: Record = { + 'Accept': 'application/json', + 'User-Agent': 'Dockhand/1.0' + }; + if (authHeader) headers['Authorization'] = authHeader; + + const repositories: string[] = []; + let totalCount = 0; + + if (orgPath) { + // Un seul projet : le path sans le slash initial + const project = orgPath.replace(/^\//, ''); + const url = `${baseUrl}/projects/${encodeURIComponent(project)}/repositories?page=${page}&page_size=${pageSize}`; + const resp = await fetch(url, { headers }); + + if (!resp.ok) { + throw new Error(`Harbor API erreur ${resp.status} pour le projet ${project}`); + } + + totalCount = parseInt(resp.headers.get('X-Total-Count') || '0', 10); + const repos: Array<{ name: string }> = await resp.json(); + for (const r of repos) { + repositories.push(r.name); + } + } else { + // Pas d'orgPath : énumérer tous les projets accessibles + const projectsResp = await fetch(`${baseUrl}/projects?page=1&page_size=100`, { headers }); + if (!projectsResp.ok) { + throw new Error(`Harbor API erreur ${projectsResp.status} pour la liste des projets`); + } + const projects: Array<{ name: string }> = await projectsResp.json(); + + // Paginer les repos du premier projet correspondant à la page demandée + // Pour simplifier, on concatène tous les repos de tous les projets + for (const proj of projects) { + const url = `${baseUrl}/projects/${encodeURIComponent(proj.name)}/repositories?page=1&page_size=100`; + const resp = await fetch(url, { headers }); + if (!resp.ok) continue; + + const repos: Array<{ name: string }> = await resp.json(); + for (const r of repos) { + repositories.push(r.name); + } + } + totalCount = repositories.length; + } + + // Calculer si il y a une page suivante + const hasMore = orgPath ? (page * pageSize < totalCount) : false; + const nextLast = hasMore ? `harbor:${page + 1}` : null; + + return { repositories, nextLast }; +} + +/** + * Recherche des repositories via l'API Harbor avec filtre q=name=~{term}. + * Parcourt tous les projets accessibles (ou un seul si orgPath défini). + * Double vérification substring côté client. + */ +export async function harborSearchRepositories( + registry: { url: string; username?: string | null; password?: string | null }, + term: string, + orgPath: string, + limit: number = 25 +): Promise { + const parsed = parseRegistryUrl(registry.url); + const baseUrl = `https://${parsed.host}/api/v2.0`; + const authHeader = getHarborBasicAuth(registry); + + const headers: Record = { + 'Accept': 'application/json', + 'User-Agent': 'Dockhand/1.0' + }; + if (authHeader) headers['Authorization'] = authHeader; + + const termLower = term.toLowerCase(); + const results: string[] = []; + + // Déterminer les projets à parcourir + let projectNames: string[]; + if (orgPath) { + projectNames = [orgPath.replace(/^\//, '')]; + } else { + const projectsResp = await fetch(`${baseUrl}/projects?page=1&page_size=100`, { headers }); + if (!projectsResp.ok) return results; + const projects: Array<{ name: string }> = await projectsResp.json(); + projectNames = projects.map(p => p.name); + } + + // Chercher dans chaque projet avec le filtre Harbor + for (const proj of projectNames) { + if (results.length >= limit) break; + + const q = encodeURIComponent(`name=~${term}`); + const url = `${baseUrl}/projects/${encodeURIComponent(proj)}/repositories?q=${q}&page=1&page_size=${limit}`; + const resp = await fetch(url, { headers }); + if (!resp.ok) continue; + + const repos: Array<{ name: string }> = await resp.json(); + for (const r of repos) { + // Double vérification côté client + if (r.name.toLowerCase().includes(termLower)) { + results.push(r.name); + if (results.length >= limit) break; + } + } + } + + return results; +} + /** * Check the registry for the current manifest digest of an image. * Simple HEAD request to get Docker-Content-Digest header. diff --git a/src/routes/api/registry/catalog/+server.ts b/src/routes/api/registry/catalog/+server.ts index e754f45..494e246 100644 --- a/src/routes/api/registry/catalog/+server.ts +++ b/src/routes/api/registry/catalog/+server.ts @@ -1,7 +1,7 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { getRegistry } from '$lib/server/db'; -import { getRegistryAuth } from '$lib/server/docker'; +import { getRegistryAuth, isHarborRegistry, harborListRepositories, parseRegistryUrl } from '$lib/server/docker'; const PAGE_SIZE = 100; @@ -24,6 +24,12 @@ export const GET: RequestHandler = async ({ url }) => { return json({ error: 'Docker Hub does not support catalog listing. Please use search instead.' }, { status: 400 }); } + // Fallback Harbor : l'endpoint _catalog est interdit pour les robots Harbor. + // On utilise l'API projet native à la place. + if (await isHarborRegistry(registry.url)) { + return handleHarborCatalog(registry, lastParam); + } + const { baseUrl, orgPath, authHeader } = await getRegistryAuth(registry, 'registry:catalog:*'); // Build catalog URL with pagination @@ -114,3 +120,39 @@ export const GET: RequestHandler = async ({ url }) => { return json({ error: 'Failed to fetch catalog: ' + (error.message || 'Unknown error') }, { status: 500 }); } }; + +/** + * Gère le catalog pour un registry Harbor via l'API projet native. + * Décode le curseur "harbor:N" pour la pagination. + */ +async function handleHarborCatalog( + registry: { url: string; username?: string | null; password?: string | null }, + lastParam: string | null +): Promise { + const { path: orgPath } = parseRegistryUrl(registry.url); + + // Décoder le curseur Harbor : "harbor:" → numéro de page + let page = 1; + if (lastParam?.startsWith('harbor:')) { + page = parseInt(lastParam.substring(7), 10) || 1; + } + + const result = await harborListRepositories(registry, orgPath, page, PAGE_SIZE); + + const results = result.repositories.map((name: string) => ({ + name, + description: '', + star_count: 0, + is_official: false, + is_automated: false + })); + + return json({ + repositories: results, + pagination: { + pageSize: PAGE_SIZE, + hasMore: !!result.nextLast, + nextLast: result.nextLast + } + }); +} diff --git a/src/routes/api/registry/search/+server.ts b/src/routes/api/registry/search/+server.ts index 3f1a2f3..190fb5f 100644 --- a/src/routes/api/registry/search/+server.ts +++ b/src/routes/api/registry/search/+server.ts @@ -1,7 +1,7 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { getRegistry } from '$lib/server/db'; -import { getRegistryAuth } from '$lib/server/docker'; +import { getRegistryAuth, isHarborRegistry, harborSearchRepositories, parseRegistryUrl } from '$lib/server/docker'; interface SearchResult { name: string; @@ -105,6 +105,12 @@ async function tryDirectImageLookup(registry: any, imageName: string): Promise { + // Fallback Harbor : utiliser l'API projet native pour la recherche + if (await isHarborRegistry(registry.url)) { + const { path: orgPath } = parseRegistryUrl(registry.url); + return harborSearchRepositories(registry, term, orgPath, limit); + } + // Note: orgPath could be used here to filter results, but search is already term-based const { baseUrl, authHeader } = await getRegistryAuth(registry, 'registry:catalog:*'); From 268ab61d1f7982ee107d6a445568f71abf96e96c Mon Sep 17 00:00:00 2001 From: Pascal GUINET Date: Fri, 20 Feb 2026 10:55:39 +0100 Subject: [PATCH 2/2] SYS-11240 Translate all code comments from French to English Co-Authored-By: Claude Opus 4.6 --- src/lib/server/docker.ts | 58 +++++++++++----------- src/routes/api/registry/catalog/+server.ts | 10 ++-- src/routes/api/registry/search/+server.ts | 2 +- 3 files changed, 35 insertions(+), 35 deletions(-) diff --git a/src/lib/server/docker.ts b/src/lib/server/docker.ts index 63ab1cf..bf771e2 100644 --- a/src/lib/server/docker.ts +++ b/src/lib/server/docker.ts @@ -2462,24 +2462,24 @@ export async function getRegistryAuth( return { baseUrl, orgPath: parsed.path, authHeader }; } -// --- Harbor fallback pour le catalog et la recherche d'images --- -// Harbor interdit l'accès au endpoint V2 _catalog pour les robots. -// On détecte Harbor et on utilise l'API projet native en fallback. +// --- Harbor fallback for catalog and image search --- +// Harbor denies access to the V2 _catalog endpoint for robot accounts. +// We detect Harbor and use the native project API as a fallback. -/** Cache de détection Harbor par host (TTL 5 min) */ +/** Harbor detection cache per host (TTL 5 min) */ const harborDetectionCache = new Map(); const HARBOR_CACHE_TTL = 5 * 60 * 1000; export interface HarborCatalogResult { repositories: string[]; - /** Curseur de pagination : "harbor:" ou null si dernière page */ + /** Pagination cursor: "harbor:" or null if last page */ nextLast: string | null; } /** - * Détecte si un registry est une instance Harbor. - * Vérifie service="harbor-registry" dans le header WWW-Authenticate de /v2/, - * puis confirme via /api/v2.0/ping. Résultat mis en cache 5 min par host. + * Detects whether a registry is a Harbor instance. + * Checks for service="harbor-registry" in the WWW-Authenticate header from /v2/, + * then confirms via /api/v2.0/ping. Result is cached for 5 min per host. */ export async function isHarborRegistry(registryUrl: string): Promise { const parsed = parseRegistryUrl(registryUrl); @@ -2494,14 +2494,14 @@ export async function isHarborRegistry(registryUrl: string): Promise { try { const baseUrl = `https://${host}`; - // Étape 1 : vérifier le header WWW-Authenticate de /v2/ + // Step 1: check the WWW-Authenticate header from /v2/ const challengeResp = await fetch(`${baseUrl}/v2/`, { method: 'GET', headers: { 'User-Agent': 'Dockhand/1.0' } }); const wwwAuth = challengeResp.headers.get('WWW-Authenticate') || ''; if (wwwAuth.toLowerCase().includes('service="harbor-registry"')) { - // Étape 2 : confirmer via /api/v2.0/ping + // Step 2: confirm via /api/v2.0/ping const pingResp = await fetch(`${baseUrl}/api/v2.0/ping`, { method: 'GET', headers: { 'User-Agent': 'Dockhand/1.0' } @@ -2514,7 +2514,7 @@ export async function isHarborRegistry(registryUrl: string): Promise { } } } catch { - // En cas d'erreur réseau, on considère que ce n'est pas Harbor + // On network error, assume it's not Harbor } harborDetectionCache.set(host, { isHarbor, ts: Date.now() }); @@ -2522,7 +2522,7 @@ export async function isHarborRegistry(registryUrl: string): Promise { } /** - * Construit le header Basic auth pour l'API Harbor à partir d'un objet registry. + * Builds the Basic auth header for the Harbor API from a registry object. */ function getHarborBasicAuth(registry: { username?: string | null; password?: string | null }): string | null { if (registry.username && registry.password) { @@ -2532,10 +2532,10 @@ function getHarborBasicAuth(registry: { username?: string | null; password?: str } /** - * Liste les repositories via l'API projet Harbor. - * Si orgPath est défini → un seul projet. Sinon → énumère tous les projets accessibles. - * @param page - numéro de page (1-based) - * @param pageSize - nombre de résultats par page + * Lists repositories via the Harbor project API. + * If orgPath is set, queries a single project. Otherwise, enumerates all accessible projects. + * @param page - page number (1-based) + * @param pageSize - number of results per page */ export async function harborListRepositories( registry: { url: string; username?: string | null; password?: string | null }, @@ -2557,13 +2557,13 @@ export async function harborListRepositories( let totalCount = 0; if (orgPath) { - // Un seul projet : le path sans le slash initial + // Single project: path without the leading slash const project = orgPath.replace(/^\//, ''); const url = `${baseUrl}/projects/${encodeURIComponent(project)}/repositories?page=${page}&page_size=${pageSize}`; const resp = await fetch(url, { headers }); if (!resp.ok) { - throw new Error(`Harbor API erreur ${resp.status} pour le projet ${project}`); + throw new Error(`Harbor API error ${resp.status} for project ${project}`); } totalCount = parseInt(resp.headers.get('X-Total-Count') || '0', 10); @@ -2572,15 +2572,15 @@ export async function harborListRepositories( repositories.push(r.name); } } else { - // Pas d'orgPath : énumérer tous les projets accessibles + // No orgPath: enumerate all accessible projects const projectsResp = await fetch(`${baseUrl}/projects?page=1&page_size=100`, { headers }); if (!projectsResp.ok) { - throw new Error(`Harbor API erreur ${projectsResp.status} pour la liste des projets`); + throw new Error(`Harbor API error ${projectsResp.status} when listing projects`); } const projects: Array<{ name: string }> = await projectsResp.json(); - // Paginer les repos du premier projet correspondant à la page demandée - // Pour simplifier, on concatène tous les repos de tous les projets + // Paginate repos from the first matching project + // For simplicity, concatenate all repos from all projects for (const proj of projects) { const url = `${baseUrl}/projects/${encodeURIComponent(proj.name)}/repositories?page=1&page_size=100`; const resp = await fetch(url, { headers }); @@ -2594,7 +2594,7 @@ export async function harborListRepositories( totalCount = repositories.length; } - // Calculer si il y a une page suivante + // Check if there is a next page const hasMore = orgPath ? (page * pageSize < totalCount) : false; const nextLast = hasMore ? `harbor:${page + 1}` : null; @@ -2602,9 +2602,9 @@ export async function harborListRepositories( } /** - * Recherche des repositories via l'API Harbor avec filtre q=name=~{term}. - * Parcourt tous les projets accessibles (ou un seul si orgPath défini). - * Double vérification substring côté client. + * Searches repositories via the Harbor API using filter q=name=~{term}. + * Iterates through all accessible projects (or a single one if orgPath is set). + * Client-side substring double-check. */ export async function harborSearchRepositories( registry: { url: string; username?: string | null; password?: string | null }, @@ -2625,7 +2625,7 @@ export async function harborSearchRepositories( const termLower = term.toLowerCase(); const results: string[] = []; - // Déterminer les projets à parcourir + // Determine which projects to iterate through let projectNames: string[]; if (orgPath) { projectNames = [orgPath.replace(/^\//, '')]; @@ -2636,7 +2636,7 @@ export async function harborSearchRepositories( projectNames = projects.map(p => p.name); } - // Chercher dans chaque projet avec le filtre Harbor + // Search each project using the Harbor filter for (const proj of projectNames) { if (results.length >= limit) break; @@ -2647,7 +2647,7 @@ export async function harborSearchRepositories( const repos: Array<{ name: string }> = await resp.json(); for (const r of repos) { - // Double vérification côté client + // Client-side double-check if (r.name.toLowerCase().includes(termLower)) { results.push(r.name); if (results.length >= limit) break; diff --git a/src/routes/api/registry/catalog/+server.ts b/src/routes/api/registry/catalog/+server.ts index 494e246..1c5567d 100644 --- a/src/routes/api/registry/catalog/+server.ts +++ b/src/routes/api/registry/catalog/+server.ts @@ -24,8 +24,8 @@ export const GET: RequestHandler = async ({ url }) => { return json({ error: 'Docker Hub does not support catalog listing. Please use search instead.' }, { status: 400 }); } - // Fallback Harbor : l'endpoint _catalog est interdit pour les robots Harbor. - // On utilise l'API projet native à la place. + // Harbor fallback: the _catalog endpoint is forbidden for Harbor robot accounts. + // Use the native project API instead. if (await isHarborRegistry(registry.url)) { return handleHarborCatalog(registry, lastParam); } @@ -122,8 +122,8 @@ export const GET: RequestHandler = async ({ url }) => { }; /** - * Gère le catalog pour un registry Harbor via l'API projet native. - * Décode le curseur "harbor:N" pour la pagination. + * Handles catalog listing for a Harbor registry via the native project API. + * Decodes the "harbor:N" cursor for pagination. */ async function handleHarborCatalog( registry: { url: string; username?: string | null; password?: string | null }, @@ -131,7 +131,7 @@ async function handleHarborCatalog( ): Promise { const { path: orgPath } = parseRegistryUrl(registry.url); - // Décoder le curseur Harbor : "harbor:" → numéro de page + // Decode the Harbor cursor: "harbor:" → page number let page = 1; if (lastParam?.startsWith('harbor:')) { page = parseInt(lastParam.substring(7), 10) || 1; diff --git a/src/routes/api/registry/search/+server.ts b/src/routes/api/registry/search/+server.ts index 190fb5f..8726ccf 100644 --- a/src/routes/api/registry/search/+server.ts +++ b/src/routes/api/registry/search/+server.ts @@ -105,7 +105,7 @@ async function tryDirectImageLookup(registry: any, imageName: string): Promise { - // Fallback Harbor : utiliser l'API projet native pour la recherche + // Harbor fallback: use the native project API for search if (await isHarborRegistry(registry.url)) { const { path: orgPath } = parseRegistryUrl(registry.url); return harborSearchRepositories(registry, term, orgPath, limit);