From eb5cf32d6820e36ece7210624a1eec873544f6d8 Mon Sep 17 00:00:00 2001 From: Pascal GUINET Date: Fri, 20 Feb 2026 08:55:43 +0100 Subject: [PATCH] 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 a0338c7..a9bd099 100644 --- a/src/lib/server/docker.ts +++ b/src/lib/server/docker.ts @@ -3168,6 +3168,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 7c8c6f3..f06c15e 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 583803a..3b7cbf3 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, parseRegistryUrl } from '$lib/server/docker'; +import { getRegistryAuth, isHarborRegistry, harborSearchRepositories, parseRegistryUrl } from '$lib/server/docker'; interface SearchResult { name: string; @@ -127,6 +127,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:*');