diff --git a/backend/src/__tests__/registries-tags.test.ts b/backend/src/__tests__/registries-tags.test.ts new file mode 100644 index 00000000..7570d38a --- /dev/null +++ b/backend/src/__tests__/registries-tags.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb'; + +vi.mock('../services/registry-api', async () => { + const actual = await vi.importActual('../services/registry-api'); + return { + ...actual, + listRegistryTagsResult: vi.fn(), + }; +}); + +import { listRegistryTagsResult } from '../services/registry-api'; + +let tmpDir: string; +let app: import('express').Express; +let authHeader: string; +let RegistryService: typeof import('../services/RegistryService').RegistryService; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ app } = await import('../index')); + ({ RegistryService } = await import('../services/RegistryService')); + const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '10m' }); + authHeader = `Bearer ${token}`; +}); + +afterAll(() => cleanupTestDb(tmpDir)); + +afterEach(() => { + vi.restoreAllMocks(); + vi.mocked(listRegistryTagsResult).mockReset(); +}); + +describe('GET /api/registries/:id/tags', () => { + it('returns tags for an exact registry id', async () => { + const id = RegistryService.getInstance().create({ + name: 'Hub', + url: 'https://index.docker.io/v1/', + type: 'dockerhub', + username: 'user', + secret: 'token', + }); + vi.mocked(listRegistryTagsResult).mockResolvedValue({ ok: true, tags: ['latest', '1.0'] }); + + const res = await request(app) + .get(`/api/registries/${id}/tags`) + .query({ repository: 'library/nginx' }) + .set('Authorization', authHeader); + + expect(res.status).toBe(200); + expect(res.body.tags).toEqual(['latest', '1.0']); + expect(res.body.registryId).toBe(id); + expect(listRegistryTagsResult).toHaveBeenCalled(); + }); + + it('maps upstream unauthorized to 424 with REGISTRY_UNAUTHORIZED (never 401)', async () => { + const id = RegistryService.getInstance().create({ + name: 'Hub2', + url: 'https://index.docker.io/v1/', + type: 'dockerhub', + username: 'user', + secret: 'bad', + }); + vi.mocked(listRegistryTagsResult).mockResolvedValue({ + ok: false, + code: 'REGISTRY_UNAUTHORIZED', + message: 'Registry rejected credentials', + }); + + const res = await request(app) + .get(`/api/registries/${id}/tags`) + .query({ repository: 'library/nginx' }) + .set('Authorization', authHeader); + + expect(res.status).toBe(424); + expect(res.body.code).toBe('REGISTRY_UNAUTHORIZED'); + expect(res.status).not.toBe(401); + }); + + it('rejects host-looking repository values', async () => { + const id = RegistryService.getInstance().create({ + name: 'GHCR', + url: 'ghcr.io', + type: 'ghcr', + username: 'user', + secret: 'token', + }); + + const res = await request(app) + .get(`/api/registries/${id}/tags`) + .query({ repository: 'ghcr.io/org/app' }) + .set('Authorization', authHeader); + + expect(res.status).toBe(400); + expect(listRegistryTagsResult).not.toHaveBeenCalled(); + }); + + it('maps REGISTRY_UPSTREAM from the client to HTTP 502', async () => { + const id = RegistryService.getInstance().create({ + name: 'Down', + url: 'https://registry.example.invalid/', + type: 'custom', + username: 'user', + secret: 'token', + }); + vi.mocked(listRegistryTagsResult).mockResolvedValue({ + ok: false, + code: 'REGISTRY_UPSTREAM', + message: 'Registry unreachable', + }); + + const res = await request(app) + .get(`/api/registries/${id}/tags`) + .query({ repository: 'org/app' }) + .set('Authorization', authHeader); + + expect(res.status).toBe(502); + expect(res.body.code).toBe('REGISTRY_UPSTREAM'); + }); + + it('returns 404 for unknown registry id', async () => { + const res = await request(app) + .get('/api/registries/999999/tags') + .query({ repository: 'org/app' }) + .set('Authorization', authHeader); + + expect(res.status).toBe(404); + }); +}); diff --git a/backend/src/__tests__/registry-api.test.ts b/backend/src/__tests__/registry-api.test.ts index 9afcf084..65d3eec9 100644 --- a/backend/src/__tests__/registry-api.test.ts +++ b/backend/src/__tests__/registry-api.test.ts @@ -39,7 +39,7 @@ function fakeRequest(url: string, options: { method?: string }, cb: (res: EventE vi.mock('https', () => ({ default: { request: (...args: unknown[]) => fakeRequest(...(args as Parameters)) } })); vi.mock('http', () => ({ default: { request: (...args: unknown[]) => fakeRequest(...(args as Parameters)) } })); -import { repoDigestMatchesRef, getRemoteDigest, getRemoteDigestResult, getAuthToken, parseImageRef } from '../services/registry-api'; +import { repoDigestMatchesRef, getRemoteDigest, getRemoteDigestResult, getAuthToken, listRegistryTagsResult, parseImageRef } from '../services/registry-api'; const TOKEN_BODY = JSON.stringify({ token: 'test-token' }); const REMOTE = 'sha256:remote000000000000000000000000000000000000000000000000000000'; @@ -234,4 +234,86 @@ describe('getRemoteDigestResult failure reasons', () => { route = () => { throw new Error('ENOTFOUND'); }; expect(await get()).toEqual({ ok: false, reason: `Registry unreachable for ${REF} (ENOTFOUND)` }); }); + + it('falls back to anonymous manifest lookup when auth transport fails', async () => { + route = (url, method): FakeResp => { + if (url.includes('auth.docker.io/token')) { + throw Object.assign(new Error('getaddrinfo ENOTFOUND'), { code: 'ENOTFOUND' }); + } + if (method === 'HEAD') return { statusCode: 200, headers: { 'docker-content-digest': REMOTE } }; + return { statusCode: 500, headers: {} }; + }; + expect(await get()).toEqual({ ok: true, digest: REMOTE }); + }); +}); + +describe('listRegistryTagsResult', () => { + const creds = { username: 'u', password: 'p' }; + const GHCR_CHALLENGE = 'Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:user/image:pull"'; + + beforeEach(() => { + calls.length = 0; + }); + + function authThenTags(tagResp: FakeResp): (url: string, method: string) => FakeResp { + return (url) => { + if (url === 'https://ghcr.io/v2/') return { statusCode: 401, headers: { 'www-authenticate': GHCR_CHALLENGE } }; + if (url.startsWith('https://ghcr.io/token')) return { statusCode: 200, headers: {}, body: TOKEN_BODY }; + if (url.includes('/tags/list')) return tagResp; + return { statusCode: 500, headers: {} }; + }; + } + + async function expectFailure(code: string, message?: string): Promise { + const result = await listRegistryTagsResult('ghcr.io', 'acme/app', creds); + expect(result).toMatchObject(message ? { ok: false, code, message } : { ok: false, code }); + } + + it('returns tags on a successful list', async () => { + route = authThenTags({ statusCode: 200, headers: {}, body: JSON.stringify({ tags: ['latest', '1.0'] }) }); + await expect(listRegistryTagsResult('ghcr.io', 'acme/app', creds)).resolves.toEqual({ + ok: true, + tags: ['latest', '1.0'], + }); + }); + + it('maps transport failure during auth ping to REGISTRY_UPSTREAM (not UNAUTHORIZED)', async () => { + route = () => { throw Object.assign(new Error('getaddrinfo ENOTFOUND'), { code: 'ENOTFOUND' }); }; + await expectFailure('REGISTRY_UPSTREAM', 'Registry unreachable'); + }); + + it('maps a rejected token to REGISTRY_UNAUTHORIZED', async () => { + route = (url): FakeResp => { + if (url === 'https://ghcr.io/v2/') return { statusCode: 401, headers: { 'www-authenticate': GHCR_CHALLENGE } }; + if (url.startsWith('https://ghcr.io/token')) return { statusCode: 401, headers: {} }; + return { statusCode: 200, headers: {}, body: '{}' }; + }; + await expectFailure('REGISTRY_UNAUTHORIZED', 'Registry rejected credentials'); + }); + + it('maps 403 on tags/list to REGISTRY_FORBIDDEN', async () => { + route = authThenTags({ statusCode: 403, headers: {} }); + await expectFailure('REGISTRY_FORBIDDEN'); + }); + + it('maps 429 on tags/list to REGISTRY_RATE_LIMITED', async () => { + route = authThenTags({ statusCode: 429, headers: {} }); + await expectFailure('REGISTRY_RATE_LIMITED'); + }); + + it('maps invalid JSON to REGISTRY_INVALID_RESPONSE', async () => { + route = authThenTags({ statusCode: 200, headers: {}, body: 'not-json' }); + await expectFailure('REGISTRY_INVALID_RESPONSE', 'Registry returned invalid JSON'); + }); + + it('maps a non-array tags field to REGISTRY_INVALID_RESPONSE', async () => { + route = authThenTags({ statusCode: 200, headers: {}, body: JSON.stringify({ tags: 'latest' }) }); + await expectFailure('REGISTRY_INVALID_RESPONSE', 'Registry tag list was malformed'); + }); + + it('maps an oversized body to REGISTRY_INVALID_RESPONSE', async () => { + const huge = '{"tags":["' + 'x'.repeat(2 * 1024 * 1024) + '"]}'; + route = authThenTags({ statusCode: 200, headers: {}, body: huge }); + await expectFailure('REGISTRY_INVALID_RESPONSE', 'Registry tag list response too large'); + }); }); diff --git a/backend/src/routes/registries.ts b/backend/src/routes/registries.ts index 6cc80b0e..42cbef4f 100644 --- a/backend/src/routes/registries.ts +++ b/backend/src/routes/registries.ts @@ -1,8 +1,10 @@ import { Router, type Request, type Response } from 'express'; import { RegistryService } from '../services/RegistryService'; +import { listRegistryTagsResult, type TagListCode } from '../services/registry-api'; import { requireAdmin, requirePaid } from '../middleware/tierGates'; import { rejectApiTokenScope } from '../middleware/apiTokenScope'; import { parseIntParam } from '../utils/parseIntParam'; +import { sanitizeForLog } from '../utils/safeLog'; const VALID_REGISTRY_TYPES = ['dockerhub', 'ghcr', 'ecr', 'custom'] as const; const REGISTRY_SCOPE_MESSAGE = 'API tokens cannot manage registry credentials.'; @@ -30,6 +32,40 @@ function allowRegistryType(type: string | undefined, req: Request, res: Response return true; } + +const REPO_MAX_LEN = 255; +const REPO_PATTERN = /^[a-z0-9]+(?:[._-][a-z0-9]+)*(?:\/[a-z0-9]+(?:[._-][a-z0-9]+)*)*$/i; + +/** Path-safe repository name only (no scheme, host, or tag). */ +function parseRepositoryParam(raw: unknown): string | null { + if (typeof raw !== 'string') return null; + const repo = raw.trim(); + if (!repo || repo.length > REPO_MAX_LEN) return null; + if (repo.includes('://') || repo.includes('@') || repo.startsWith('/') || repo.includes(':')) return null; + const first = repo.split('/')[0]; + // Host-looking first segment (e.g. ghcr.io/org/name) is rejected; host comes from the registry row. + if (first.includes('.')) return null; + if (!REPO_PATTERN.test(repo)) return null; + return repo; +} + +function tagListHttpStatus(code: TagListCode): number { + switch (code) { + case 'REGISTRY_UNAUTHORIZED': + case 'REGISTRY_FORBIDDEN': + case 'REGISTRY_RATE_LIMITED': + case 'REGISTRY_UNSUPPORTED': + return 424; + case 'REGISTRY_NOT_FOUND': + return 404; + case 'REGISTRY_INVALID_RESPONSE': + return 400; + case 'REGISTRY_UPSTREAM': + default: + return 502; + } +} + export const registriesRouter = Router(); registriesRouter.get('/', (req: Request, res: Response): void => { @@ -136,6 +172,80 @@ registriesRouter.delete('/:id', (req: Request, res: Response): void => { } }); + +// Browse tags for a configured registry + validated repository (hub-only). +// Upstream registry auth failures map to 424/502 — never HTTP 401 (that would +// log the browser session out via the frontend unauthorized handler). +registriesRouter.get('/:id/tags', async (req: Request, res: Response): Promise => { + if (rejectApiTokenScope(req, res, REGISTRY_SCOPE_MESSAGE)) return; + if (!requireAdmin(req, res)) return; + try { + const id = parseIntParam(req, res, 'id', 'registry ID'); + if (id === null) return; + + const repository = parseRepositoryParam(req.query.repository); + if (!repository) { + res.status(400).json({ error: 'Query parameter repository is required and must be a path-safe repository name' }); + return; + } + + const limitRaw = req.query.limit; + let limit = 50; + if (limitRaw !== undefined) { + const n = typeof limitRaw === 'string' ? Number.parseInt(limitRaw, 10) : NaN; + if (!Number.isFinite(n) || n < 1 || n > 100) { + res.status(400).json({ error: 'limit must be an integer from 1 to 100' }); + return; + } + limit = n; + } + const cursor = typeof req.query.cursor === 'string' && req.query.cursor.trim() + ? req.query.cursor.trim() + : undefined; + + const auth = await RegistryService.getInstance().getAuthForRegistryId(id); + if (!auth.ok) { + if (auth.code === 'missing') { + res.status(404).json({ error: auth.message, code: 'REGISTRY_NOT_FOUND' }); + return; + } + const code = auth.code === 'ecr_failed' || auth.code === 'decrypt_failed' + ? 'REGISTRY_UNAUTHORIZED' + : 'REGISTRY_UPSTREAM'; + res.status(424).json({ error: auth.message, code }); + return; + } + if (!allowRegistryType(auth.type, req, res)) return; + + // Docker Hub official images are often referenced as library/. + let repo = repository; + if (auth.type === 'dockerhub' && !repo.includes('/')) { + repo = `library/${repo}`; + } + + const result = await listRegistryTagsResult( + auth.registryHost, + repo, + { username: auth.username, password: auth.password }, + { limit, cursor }, + ); + if (!result.ok) { + res.status(tagListHttpStatus(result.code)).json({ error: result.message, code: result.code }); + return; + } + res.json({ + tags: result.tags, + nextCursor: result.nextCursor ?? null, + registryId: id, + registryName: auth.name, + repository: repo, + }); + } catch (error) { + console.error('[Registries] Tag list error:', sanitizeForLog(error instanceof Error ? error.message : String(error))); + res.status(500).json({ error: 'Failed to list registry tags' }); + } +}); + registriesRouter.post('/:id/test', async (req: Request, res: Response): Promise => { if (rejectApiTokenScope(req, res, REGISTRY_SCOPE_MESSAGE)) return; if (!requireAdmin(req, res)) return; diff --git a/backend/src/services/RegistryService.ts b/backend/src/services/RegistryService.ts index 6db2686c..f7b6e3ad 100644 --- a/backend/src/services/RegistryService.ts +++ b/backend/src/services/RegistryService.ts @@ -42,6 +42,23 @@ export interface ResolvedDockerConfig { warnings: string[]; } +export type RegistryAuthForIdResult = + | { + ok: true; + username: string; + password: string; + registryHost: string; + type: RegistryType; + name: string; + } + | { ok: false; code: 'missing' | 'decrypt_failed' | 'ecr_failed'; message: string }; + +/** Host used for Docker Registry HTTP API calls (/v2/...). */ +export function registryApiHost(reg: Pick): string { + if (reg.type === 'dockerhub') return 'registry-1.docker.io'; + return hostFromStoredRegistry(reg); +} + interface HttpResult { statusCode: number; headers: Record; @@ -83,7 +100,7 @@ function toProbeUrl(url: string, type: RegistryType): string { } /** Canonical host for matching (image ref → stored credential). */ -function hostFromStoredRegistry(reg: Pick): string { +export function hostFromStoredRegistry(reg: Pick): string { if (reg.type === 'dockerhub') return 'index.docker.io'; try { const withProtocol = reg.url.startsWith('http') ? reg.url : `https://${reg.url}`; @@ -375,6 +392,50 @@ export class RegistryService { return { config: { auths }, warnings }; } + + /** + * Resolve credentials for a specific registry row by ID only. + * Never falls back to host-based matching (duplicate hosts must use the requested ID). + */ + public async getAuthForRegistryId(id: number): Promise { + const reg = DatabaseService.getInstance().getRegistry(id); + if (!reg) { + return { ok: false, code: 'missing', message: 'Registry not found' }; + } + try { + const registryHost = registryApiHost(reg); + if (reg.type === 'ecr') { + const creds = await this.getEcrCredentials(reg); + return { + ok: true, + username: creds.username, + password: creds.password, + registryHost, + type: reg.type, + name: reg.name, + }; + } + return { + ok: true, + username: reg.username, + password: this.crypto.decrypt(reg.secret), + registryHost, + type: reg.type, + name: reg.name, + }; + } catch (e) { + const detail = e instanceof Error ? e.message : String(e); + console.warn( + `[RegistryService] getAuthForRegistryId(${id}) failed:`, + sanitizeForLog(detail), + ); + if (reg.type === 'ecr') { + return { ok: false, code: 'ecr_failed', message: 'Failed to refresh ECR credentials' }; + } + return { ok: false, code: 'decrypt_failed', message: 'Failed to decrypt registry credentials' }; + } + } + // ─── Registry auth for ImageUpdateService ──────────────────────────────── public async getAuthForRegistry(registryHost: string): Promise<{ username: string; password: string } | null> { diff --git a/backend/src/services/registry-api.ts b/backend/src/services/registry-api.ts index 5f8c1772..474b6769 100644 --- a/backend/src/services/registry-api.ts +++ b/backend/src/services/registry-api.ts @@ -98,39 +98,41 @@ export async function getAuthToken( repo: string, credentials?: RegistryCredentials | null, ): Promise { + // Transport errors propagate (callers map to REGISTRY_UPSTREAM). null = auth/token failure only. + const basicHeaders: Record = {}; + if (credentials) { + basicHeaders['Authorization'] = `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`; + } + + let tokenUrl: string; + if (registry === 'registry-1.docker.io') { + tokenUrl = `https://auth.docker.io/token?service=registry.docker.io&scope=repository:${repo}:pull`; + } else { + const ping = await httpGet(`https://${registry}/v2/`, basicHeaders); + const wwwAuth = ping.headers['www-authenticate'] as string | undefined; + if (!wwwAuth) return null; + + const realmMatch = wwwAuth.match(/realm="([^"]+)"/); + const serviceMatch = wwwAuth.match(/service="([^"]+)"/); + if (!realmMatch) return null; + + const params = new URLSearchParams(); + if (serviceMatch) params.set('service', serviceMatch[1]); + // The /v2/ ping carries no repository context, so any scope it echoes is a + // placeholder (ghcr.io returns repository:user/image:pull). Always request + // the scope for the repository we actually want; reusing the echoed scope + // makes ghcr.io mint a token for the wrong repo and then reject the pull. + params.set('scope', `repository:${repo}:pull`); + tokenUrl = `${realmMatch[1]}?${params.toString()}`; + } + + const tokenRes = await httpGet(tokenUrl, basicHeaders); + if (tokenRes.statusCode !== 200) return null; + try { - const basicHeaders: Record = {}; - if (credentials) { - basicHeaders['Authorization'] = `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`; - } - - let tokenUrl: string; - if (registry === 'registry-1.docker.io') { - tokenUrl = `https://auth.docker.io/token?service=registry.docker.io&scope=repository:${repo}:pull`; - } else { - const ping = await httpGet(`https://${registry}/v2/`, basicHeaders); - const wwwAuth = ping.headers['www-authenticate'] as string | undefined; - if (!wwwAuth) return null; - - const realmMatch = wwwAuth.match(/realm="([^"]+)"/); - const serviceMatch = wwwAuth.match(/service="([^"]+)"/); - if (!realmMatch) return null; - - const params = new URLSearchParams(); - if (serviceMatch) params.set('service', serviceMatch[1]); - // The /v2/ ping carries no repository context, so any scope it echoes is a - // placeholder (ghcr.io returns repository:user/image:pull). Always request - // the scope for the repository we actually want; reusing the echoed scope - // makes ghcr.io mint a token for the wrong repo and then reject the pull. - params.set('scope', `repository:${repo}:pull`); - tokenUrl = `${realmMatch[1]}?${params.toString()}`; - } - - const tokenRes = await httpGet(tokenUrl, basicHeaders); - if (tokenRes.statusCode !== 200) return null; - - const parsed = JSON.parse(tokenRes.body); - return parsed.token ?? parsed.access_token ?? null; + const parsed = JSON.parse(tokenRes.body) as { token?: unknown; access_token?: unknown }; + const token = parsed.token ?? parsed.access_token; + return typeof token === 'string' ? token : null; } catch { return null; } @@ -210,7 +212,21 @@ export async function getRemoteDigestResult( ): Promise { const ref = `${registry}/${repo}:${tag}`; try { - const token = await getAuthToken(registry, repo, credentials); + // Auth transport failures used to collapse to null inside getAuthToken. + // Tag listing now needs those errors to propagate (REGISTRY_UPSTREAM), so + // digest lookup keeps anonymous fallback here when the token endpoint is down. + let token: string | null = null; + try { + token = await getAuthToken(registry, repo, credentials); + } catch (authErr) { + const cause = authErr instanceof Error + ? ((authErr as NodeJS.ErrnoException).code ?? authErr.message) + : String(authErr); + console.error( + `[registry-api] Auth for ${sanitizeForLog(ref)} failed; trying anonymous:`, + sanitizeForLog(cause), + ); + } const headers: Record = { Accept: MANIFEST_ACCEPT }; if (token) headers['Authorization'] = `Bearer ${token}`; const url = `https://${registry}/v2/${repo}/manifests/${tag}`; @@ -260,22 +276,105 @@ export async function getRemoteDigest( return result.ok ? result.digest : null; } +export type TagListCode = + | 'REGISTRY_UNAUTHORIZED' + | 'REGISTRY_FORBIDDEN' + | 'REGISTRY_NOT_FOUND' + | 'REGISTRY_RATE_LIMITED' + | 'REGISTRY_UNSUPPORTED' + | 'REGISTRY_UPSTREAM' + | 'REGISTRY_INVALID_RESPONSE'; + +export type TagListResult = + | { ok: true; tags: string[]; nextCursor?: string } + | { ok: false; code: TagListCode; message: string }; + +const TAG_LIST_BODY_CAP = 2 * 1024 * 1024; // 2 MiB + +function tagListFailure(statusCode: number): TagListResult { + if (statusCode === 401) { + return { ok: false, code: 'REGISTRY_UNAUTHORIZED', message: 'Registry rejected credentials' }; + } + if (statusCode === 403) { + return { ok: false, code: 'REGISTRY_FORBIDDEN', message: 'Registry denied access to this repository' }; + } + if (statusCode === 404) { + return { ok: false, code: 'REGISTRY_NOT_FOUND', message: 'Repository not found on registry' }; + } + if (statusCode === 429) { + return { ok: false, code: 'REGISTRY_RATE_LIMITED', message: 'Registry rate limit exceeded' }; + } + if (statusCode >= 500) { + return { ok: false, code: 'REGISTRY_UPSTREAM', message: `Registry error (${statusCode})` }; + } + return { ok: false, code: 'REGISTRY_UPSTREAM', message: `Registry returned status ${statusCode}` }; +} + +function parseNextCursor(linkHeader: string | string[] | undefined): string | undefined { + const raw = Array.isArray(linkHeader) ? linkHeader.join(',') : linkHeader; + if (!raw) return undefined; + // Rel=next Link: ; rel="next" + const match = raw.match(/<[^>]*[?&]last=([^&>]+)[^>]*>\s*;\s*rel="?next"?/i); + if (!match) return undefined; + try { + return decodeURIComponent(match[1]); + } catch { + return match[1]; + } +} + +/** + * Typed tag list for the Resources registry browser. Never collapses auth + * failures into an empty array (that would hide credential problems). + */ +export async function listRegistryTagsResult( + registry: string, + repo: string, + credentials: RegistryCredentials, + opts: { limit?: number; cursor?: string } = {}, +): Promise { + const limit = Math.min(Math.max(opts.limit ?? 50, 1), 100); + try { + const token = await getAuthToken(registry, repo, credentials); + if (!token) { + return { ok: false, code: 'REGISTRY_UNAUTHORIZED', message: 'Registry rejected credentials' }; + } + const headers: Record = { Accept: 'application/json', Authorization: `Bearer ${token}` }; + const params = new URLSearchParams({ n: String(limit) }); + if (opts.cursor) params.set('last', opts.cursor); + const url = `https://${registry}/v2/${repo}/tags/list?${params.toString()}`; + const res = await httpGet(url, headers); + if (res.statusCode !== 200) return tagListFailure(res.statusCode); + if (res.body.length > TAG_LIST_BODY_CAP) { + return { ok: false, code: 'REGISTRY_INVALID_RESPONSE', message: 'Registry tag list response too large' }; + } + let parsed: { tags?: unknown }; + try { + parsed = JSON.parse(res.body) as { tags?: unknown }; + } catch { + return { ok: false, code: 'REGISTRY_INVALID_RESPONSE', message: 'Registry returned invalid JSON' }; + } + if (!Array.isArray(parsed.tags) || !parsed.tags.every((t) => typeof t === 'string')) { + return { ok: false, code: 'REGISTRY_INVALID_RESPONSE', message: 'Registry tag list was malformed' }; + } + const nextCursor = parseNextCursor(res.headers['link']); + return nextCursor + ? { ok: true, tags: parsed.tags as string[], nextCursor } + : { ok: true, tags: parsed.tags as string[] }; + } catch (e) { + const cause = e instanceof Error ? ((e as NodeJS.ErrnoException).code ?? e.message) : String(e); + console.error('[registry-api] Tag list failed:', sanitizeForLog(cause)); + return { ok: false, code: 'REGISTRY_UPSTREAM', message: 'Registry unreachable' }; + } +} + +/** Compatibility wrapper for update-preview: empty list on any failure. */ export async function listRegistryTags( registry: string, repo: string, credentials?: RegistryCredentials | null, ): Promise { - try { - const token = await getAuthToken(registry, repo, credentials); - const headers: Record = { Accept: 'application/json' }; - if (token) headers['Authorization'] = `Bearer ${token}`; - - const res = await httpGet(`https://${registry}/v2/${repo}/tags/list`, headers); - if (res.statusCode !== 200) return []; - - const parsed = JSON.parse(res.body) as { tags?: string[] }; - return Array.isArray(parsed.tags) ? parsed.tags : []; - } catch { - return []; - } + if (!credentials) return []; + const result = await listRegistryTagsResult(registry, repo, credentials); + return result.ok ? result.tags : []; } diff --git a/docs/features/private-registries.mdx b/docs/features/private-registries.mdx index 55b3602a..d6b2edec 100644 --- a/docs/features/private-registries.mdx +++ b/docs/features/private-registries.mdx @@ -25,6 +25,14 @@ Sencho stores credentials for your private Docker registries and injects them au AWS ECR requires a Sencho **Admiral** license; Docker Hub, GHCR, and custom registries are available on every tier. + + +## Browse tags from Resources + +When you inspect an image on the Resources page, the detail sheet includes a **Registry tags** section for admins. Sencho lists tags from the configured registry that matches the image host (credentials come from that registry row only). Tag browsing always runs on the hub instance; local image digests still come from the active node. + +If credentials are wrong, Sencho reports a registry error without signing you out of the dashboard. + ## Where to find it Open **Settings → Infrastructure → Registries** on the Sencho instance you are signed into directly. The section is hidden when you are viewing another node through the node switcher. diff --git a/docs/features/resources.mdx b/docs/features/resources.mdx index d55611b0..5054e395 100644 --- a/docs/features/resources.mdx +++ b/docs/features/resources.mdx @@ -83,13 +83,15 @@ Lists all Docker images on the host with their ID, repository tag, size, and sta #### Inspect image -Click the eye icon on any image row to open a detail sheet with three sections: +Click the eye icon on any image row to open a detail sheet with these sections: -- **Overview** lists the ID, size, creation date, architecture and OS, author, all repository tags, and which Sencho stacks use the image. +- **Used by** lists every Sencho stack that references the image (chips open that stack). Sourced from the Resources inventory, so it stays visible while inspect loads or if inspect fails. +- **Overview** lists the ID, size, creation date, architecture and OS, author, and all repository tags. - **Config** shows the default `Cmd`, `Entrypoint`, `WorkingDir`, `User`, exposed ports, environment variables, and labels. Env vars and labels are collapsible. - **Layers** lists the layer history in build order. Each row shows the layer index, size, age, and the build command (`CreatedBy`). Empty layers (metadata-only, zero bytes) are dimmed. +- **Registry tags** (admins) lists remote tags from a matching configured registry. See [Private registries](/features/private-registries) for credential and failure behavior. -The inspect sheet is read-only and available to all roles. +The inspect sheet is available to all roles. Registry tag browsing is admin-only. ### Volumes diff --git a/docs/openapi.yaml b/docs/openapi.yaml index acf01b9f..5d96fe4c 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -644,6 +644,26 @@ components: description: Required when `type` is `ecr`. example: us-east-1 + RegistryTagList: + type: object + required: [tags, nextCursor, registryId, registryName, repository] + properties: + tags: + type: array + items: { type: string } + example: [latest, "1.0"] + nextCursor: + type: ["string", "null"] + description: Opaque cursor for the next page, or null when there are no more tags. + registryId: + type: integer + registryName: + type: string + repository: + type: string + description: Path-safe repository name after Docker Hub library/ normalization when applicable. + example: library/nginx + ImageUpdateStatus: type: object properties: @@ -3605,3 +3625,44 @@ paths: $ref: "#/components/responses/Unauthorized" "403": description: Non-admin users cannot change the schedule. + + /api/registries/{id}/tags: + get: + tags: [Registries] + summary: List tags for a repository on a configured registry + description: | + Hub-only. Admin only. Credentials are resolved by exact registry ID. + Upstream registry auth failures return 424 with a REGISTRY_* code, never HTTP 401. + parameters: + - name: id + in: path + required: true + schema: { type: integer } + - name: repository + in: query + required: true + schema: { type: string } + description: Path-safe repository name (no host or scheme) + - name: cursor + in: query + required: false + schema: { type: string } + - name: limit + in: query + required: false + schema: { type: integer, minimum: 1, maximum: 100, default: 50 } + responses: + '200': + description: Tag page + content: + application/json: + schema: + $ref: "#/components/schemas/RegistryTagList" + '400': + description: Invalid repository or response + '404': + description: Registry or repository not found + '424': + description: Upstream registry auth or policy failure (not a Sencho session 401) + '502': + description: Upstream registry transport failure diff --git a/frontend/src/components/resources/ImageDetailsSheet.tsx b/frontend/src/components/resources/ImageDetailsSheet.tsx index 1d39d8e6..46bfef0f 100644 --- a/frontend/src/components/resources/ImageDetailsSheet.tsx +++ b/frontend/src/components/resources/ImageDetailsSheet.tsx @@ -7,6 +7,8 @@ import { apiFetch } from '@/lib/api'; import { formatBytes } from '@/lib/utils'; import { copyToClipboard } from '@/lib/clipboard'; import { formatShortDigest } from '@/lib/formatDigest'; +import { useAuth } from '@/context/AuthContext'; +import { RegistryTagsPanel } from './RegistryTagsPanel'; import { Copy } from 'lucide-react'; interface ImageInspect { @@ -76,6 +78,7 @@ function formatRelativeAge(timestampSec: number): string { export function ImageDetailsSheet({ image, onClose, onOpenStack }: ImageDetailsSheetProps) { const imageId = image?.Id ?? null; + const { isAdmin } = useAuth(); const [data, setData] = useState(null); const [loading, setLoading] = useState(false); @@ -113,7 +116,6 @@ export function ImageDetailsSheet({ image, onClose, onOpenStack }: ImageDetailsS const inspect = data?.inspect; const history = data?.history ?? []; const totalLayers = history.length; - const usedByStacks = image?.usedByStacks ?? []; const name = image?.RepoTags?.[0] || inspect?.RepoTags?.[0] @@ -145,6 +147,33 @@ export function ImageDetailsSheet({ image, onClose, onOpenStack }: ImageDetailsS )} + {image && ( + + {(image.usedByStacks?.length ?? 0) === 0 ? ( +

+ {image.managedStatus === 'unused' ? 'No containers' : 'Not used by a Sencho stack'} +

+ ) : ( +
+ {image.usedByStacks.map((stack) => ( + onOpenStack ? ( + + ) : ( + {stack} + ) + ))} +
+ )} +
+ )} + {!loading && inspect && ( <> @@ -191,30 +220,6 @@ export function ImageDetailsSheet({ image, onClose, onOpenStack }: ImageDetailsS )} - - {usedByStacks.length === 0 ? ( -

- {image?.managedStatus === 'unused' ? 'No containers' : 'Not used by a Sencho stack'} -

- ) : ( -
- {usedByStacks.map((stack) => ( - onOpenStack ? ( - - ) : ( - {stack} - ) - ))} -
- )} -
@@ -279,6 +284,15 @@ export function ImageDetailsSheet({ image, onClose, onOpenStack }: ImageDetailsS )} + + + + )} diff --git a/frontend/src/components/resources/RegistryTagsPanel.tsx b/frontend/src/components/resources/RegistryTagsPanel.tsx new file mode 100644 index 00000000..031687e0 --- /dev/null +++ b/frontend/src/components/resources/RegistryTagsPanel.tsx @@ -0,0 +1,287 @@ +import { useEffect, useMemo, useState, type ReactNode } from 'react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { toast } from '@/components/ui/toast-store'; +import { apiFetch } from '@/lib/api'; +import { Loader2 } from 'lucide-react'; + +interface RegistryRow { + id: number; + name: string; + url: string; + type: 'dockerhub' | 'ghcr' | 'ecr' | 'custom'; + has_secret: boolean; +} + +interface TagListResponse { + tags: string[]; + nextCursor: string | null; + registryId: number; + registryName: string; + repository: string; +} + +export interface RegistryTagsPanelProps { + repoTags: string[]; + repoDigests: string[]; + nodeId: string | number; + isAdmin: boolean; +} + +function parseRepoFromTag(tag: string): { host: string; repo: string; tagName: string } | null { + if (!tag || tag === ':') return null; + const at = tag.indexOf('@'); + const ref = at === -1 ? tag : tag.slice(0, at); + let rest = ref; + let host = 'docker.io'; + const slash = ref.indexOf('/'); + if (slash !== -1) { + const first = ref.slice(0, slash); + if (first.includes('.') || first.includes(':') || first === 'localhost') { + host = first.toLowerCase(); + rest = ref.slice(slash + 1); + } + } + let tagName = 'latest'; + const colon = rest.lastIndexOf(':'); + if (colon > 0) { + tagName = rest.slice(colon + 1); + rest = rest.slice(0, colon); + } + if (host === 'docker.io' && !rest.includes('/')) { + rest = `library/${rest}`; + } + return { host, repo: rest, tagName }; +} + +function registryMatchesHost(reg: RegistryRow, host: string): boolean { + const h = host.toLowerCase(); + if (reg.type === 'dockerhub') { + return h === 'docker.io' || h === 'index.docker.io' || h === 'registry-1.docker.io' || h === ''; + } + try { + const withProto = reg.url.startsWith('http') ? reg.url : `https://${reg.url}`; + return new URL(withProto).host.toLowerCase() === h; + } catch { + return reg.url.replace(/^https?:\/\//i, '').split('/')[0].toLowerCase() === h; + } +} + +export function RegistryTagsPanel({ + repoTags, + isAdmin, +}: RegistryTagsPanelProps) { + const candidates = useMemo(() => { + const seen = new Set(); + const out: { host: string; repo: string; tagName: string; label: string }[] = []; + for (const t of repoTags) { + const parsed = parseRepoFromTag(t); + if (!parsed) continue; + const key = `${parsed.host}/${parsed.repo}`; + if (seen.has(key)) continue; + seen.add(key); + out.push({ ...parsed, label: t }); + } + return out; + }, [repoTags]); + + const [registries, setRegistries] = useState([]); + const [loadingRegs, setLoadingRegs] = useState(false); + const [regsError, setRegsError] = useState(null); + const [selectedRepoIdx, setSelectedRepoIdx] = useState(0); + const [selectedRegistryId, setSelectedRegistryId] = useState(null); + const [tags, setTags] = useState([]); + const [nextCursor, setNextCursor] = useState(null); + const [loadingTags, setLoadingTags] = useState(false); + const [error, setError] = useState(null); + + const selected = candidates[selectedRepoIdx] ?? null; + + const matchingRegistries = useMemo(() => { + if (!selected) return []; + return registries.filter((r) => registryMatchesHost(r, selected.host)); + }, [registries, selected]); + + useEffect(() => { + if (!isAdmin) return; + let cancelled = false; + setLoadingRegs(true); + setRegsError(null); + apiFetch('/registries', { localOnly: true }) + .then(async (res) => { + if (!res.ok) throw new Error('Failed to load registries'); + return res.json() as Promise; + }) + .then((rows) => { + if (!cancelled) setRegistries(Array.isArray(rows) ? rows : []); + }) + .catch((err: unknown) => { + if (cancelled) return; + const msg = err instanceof Error ? err.message : 'Failed to load registries'; + setRegsError(msg); + setRegistries([]); + toast.error(msg); + }) + .finally(() => { + if (!cancelled) setLoadingRegs(false); + }); + return () => { cancelled = true; }; + }, [isAdmin]); + + useEffect(() => { + if (matchingRegistries.length === 0) { + setSelectedRegistryId(null); + return; + } + setSelectedRegistryId((prev) => + prev && matchingRegistries.some((r) => r.id === prev) ? prev : matchingRegistries[0].id, + ); + }, [matchingRegistries]); + + const loadTags = async (cursor?: string | null, append = false) => { + if (!selected || selectedRegistryId == null) return; + setLoadingTags(true); + setError(null); + try { + const params = new URLSearchParams({ repository: selected.repo, limit: '50' }); + if (cursor) params.set('cursor', cursor); + const res = await apiFetch(`/registries/${selectedRegistryId}/tags?${params}`, { localOnly: true }); + const data = await res.json().catch(() => null) as (TagListResponse & { error?: string }) | null; + if (!res.ok) { + throw new Error(data?.error || `Failed to list tags (${res.status})`); + } + const page = Array.isArray(data?.tags) ? data!.tags : []; + setTags((prev) => (append ? [...prev, ...page] : page)); + setNextCursor(data?.nextCursor ?? null); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Failed to list tags'; + setError(msg); + if (!append) setTags([]); + toast.error(msg); + } finally { + setLoadingTags(false); + } + }; + + useEffect(() => { + setTags([]); + setNextCursor(null); + setError(null); + if (selected && selectedRegistryId != null) { + void loadTags(null, false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selected?.host, selected?.repo, selectedRegistryId]); + + if (!isAdmin) { + return ( +

+ Registry tag browsing is available to admins with a configured registry. +

+ ); + } + + if (candidates.length === 0) { + return

No repository tags to browse.

; + } + + let registryBody: ReactNode; + if (loadingRegs) { + registryBody = ( +

+ Loading registries… +

+ ); + } else if (regsError) { + registryBody =

{regsError}

; + } else if (matchingRegistries.length === 0) { + registryBody = ( +

+ No configured registry matches {selected?.host}. Add credentials under Settings → Registries. +

+ ); + } else { + registryBody = ( + <> + {matchingRegistries.length > 1 && ( + + )} + + {error &&

{error}

} + + {loadingTags && tags.length === 0 ? ( +

+ Loading tags… +

+ ) : tags.length === 0 && !error ? ( +

No tags returned.

+ ) : ( +
    + {tags.map((tag) => { + const isCurrent = selected?.tagName === tag; + return ( +
  • + + {tag} + + {isCurrent && ( + current + )} +
  • + ); + })} +
+ )} + + {nextCursor && ( + + )} + + ); + } + + return ( +
+ {candidates.length > 1 && ( +
+ {candidates.map((c, i) => ( + + ))} +
+ )} + {registryBody} +
+ ); +} + diff --git a/frontend/src/components/resources/__tests__/ImageDetailsSheet.test.tsx b/frontend/src/components/resources/__tests__/ImageDetailsSheet.test.tsx index 88f558db..e2399bbb 100644 --- a/frontend/src/components/resources/__tests__/ImageDetailsSheet.test.tsx +++ b/frontend/src/components/resources/__tests__/ImageDetailsSheet.test.tsx @@ -5,6 +5,7 @@ import { ImageDetailsSheet } from '../ImageDetailsSheet'; const apiFetch = vi.fn(); vi.mock('@/lib/api', () => ({ apiFetch: (...args: unknown[]) => apiFetch(...args) })); +vi.mock('@/context/AuthContext', () => ({ useAuth: () => ({ isAdmin: true }) })); vi.mock('@/components/ui/toast-store', () => ({ toast: { error: vi.fn(), success: vi.fn() }, })); diff --git a/frontend/src/components/resources/__tests__/RegistryTagsPanel.test.tsx b/frontend/src/components/resources/__tests__/RegistryTagsPanel.test.tsx new file mode 100644 index 00000000..048a2dde --- /dev/null +++ b/frontend/src/components/resources/__tests__/RegistryTagsPanel.test.tsx @@ -0,0 +1,45 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { RegistryTagsPanel } from '../RegistryTagsPanel'; + +const apiFetch = vi.fn(); +const toastError = vi.fn(); +vi.mock('@/lib/api', () => ({ apiFetch: (...args: unknown[]) => apiFetch(...args) })); +vi.mock('@/components/ui/toast-store', () => ({ + toast: { error: (...args: unknown[]) => toastError(...args), success: vi.fn() }, +})); + +describe('RegistryTagsPanel', () => { + beforeEach(() => { + apiFetch.mockReset(); + toastError.mockReset(); + }); + + const panelProps = { + repoTags: ['ghcr.io/acme/app:latest'], + repoDigests: [] as string[], + nodeId: 1, + isAdmin: true as const, + }; + + it('surfaces registry list failures instead of a no-match message', async () => { + apiFetch.mockResolvedValue({ ok: false, status: 500, json: async () => ({ error: 'boom' }) }); + + render(); + + expect(await screen.findByText('Failed to load registries')).toBeInTheDocument(); + expect(screen.queryByText(/No configured registry matches/i)).toBeNull(); + expect(toastError).toHaveBeenCalled(); + }); + + it('shows no-match copy when registries load but none match the host', async () => { + apiFetch.mockResolvedValue({ + ok: true, + json: async () => ([{ id: 1, name: 'Hub', url: 'https://index.docker.io/v1/', type: 'dockerhub', has_secret: true }]), + }); + + render(); + + expect(await screen.findByText(/No configured registry matches ghcr.io/i)).toBeInTheDocument(); + }); +}); \ No newline at end of file