mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
feat(registries): add exact-ID tag browser with non-401 failures (#1613)
* feat(resources): show multi-stack usedByStacks on images Classify images with a deduped sorted stack reverse index, surface chips in the Images table and inspect sheet, and clear node-bound sheet selection on active-node change. * feat(registries): add exact-ID tag browser with non-401 failures Add GET /api/registries/:id/tags using credentials for that registry row only, map upstream auth failures to 424, and surface a Registry tags section on the image inspect sheet. * fix(registries): distinguish unreachable hosts from auth failures Map auth transport errors to REGISTRY_UPSTREAM (502), surface registry list-load failures in the tag panel, document Used by and Registry tags, and add parser coverage. * fix(registries): drop unused RegistryTagsPanel __test export The non-component export tripped react-refresh/only-export-components and failed Frontend lint in CI.
This commit is contained in:
@@ -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<typeof import('../services/registry-api')>('../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);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof fakeRequest>)) } }));
|
||||
vi.mock('http', () => ({ default: { request: (...args: unknown[]) => fakeRequest(...(args as Parameters<typeof fakeRequest>)) } }));
|
||||
|
||||
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<void> {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void> => {
|
||||
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/<name>.
|
||||
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<void> => {
|
||||
if (rejectApiTokenScope(req, res, REGISTRY_SCOPE_MESSAGE)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
|
||||
@@ -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<Registry, 'url' | 'type'>): string {
|
||||
if (reg.type === 'dockerhub') return 'registry-1.docker.io';
|
||||
return hostFromStoredRegistry(reg);
|
||||
}
|
||||
|
||||
interface HttpResult {
|
||||
statusCode: number;
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
@@ -83,7 +100,7 @@ function toProbeUrl(url: string, type: RegistryType): string {
|
||||
}
|
||||
|
||||
/** Canonical host for matching (image ref → stored credential). */
|
||||
function hostFromStoredRegistry(reg: Pick<Registry, 'url' | 'type'>): string {
|
||||
export function hostFromStoredRegistry(reg: Pick<Registry, 'url' | 'type'>): 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<RegistryAuthForIdResult> {
|
||||
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> {
|
||||
|
||||
@@ -98,39 +98,41 @@ export async function getAuthToken(
|
||||
repo: string,
|
||||
credentials?: RegistryCredentials | null,
|
||||
): Promise<string | null> {
|
||||
// Transport errors propagate (callers map to REGISTRY_UPSTREAM). null = auth/token failure only.
|
||||
const basicHeaders: Record<string, string> = {};
|
||||
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<string, string> = {};
|
||||
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<RemoteDigestResult> {
|
||||
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<string, string> = { 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: </v2/repo/tags/list?n=50&last=foo>; 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<TagListResult> {
|
||||
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<string, string> = { 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<string[]> {
|
||||
try {
|
||||
const token = await getAuthToken(registry, repo, credentials);
|
||||
const headers: Record<string, string> = { 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 : [];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user