Files
peardock/server/services/registry-client.js
T
Raven Scott f6f059ab94 Add remote registry repository delete
Delete all tags/manifests for a repository via Registry V2 digests, with
admin RPC, audit logging, and catalog/tags UI actions.
2026-07-17 19:43:53 -04:00

685 lines
20 KiB
JavaScript

/**
* Docker Registry HTTP API V2 client (catalog, tags, manifests, delete).
*
* Works on Node and Bare (host + agent:false — see image-updates notes).
* Auth: Basic and Bearer (WWW-Authenticate), using vault/session credentials.
*/
import https from 'https'
import http from 'http'
const DEFAULT_TIMEOUT_MS = Math.min(
45_000,
Number(process.env.PEARDOCK_REGISTRY_TIMEOUT_MS) || 20_000
)
const MANIFEST_ACCEPT = [
'application/vnd.oci.image.index.v1+json',
'application/vnd.docker.distribution.manifest.list.v2+json',
'application/vnd.oci.image.manifest.v1+json',
'application/vnd.docker.distribution.manifest.v2+json',
'application/vnd.docker.distribution.manifest.v1+json',
].join(', ')
/**
* Normalize a registry server address from vault / Docker login form to
* { baseUrl, host, registryApiHost, isDockerHub }.
* @param {string} [serveraddress]
*/
export function normalizeRegistryEndpoint(serveraddress) {
let raw = String(serveraddress || 'https://index.docker.io/v1/').trim()
if (!raw) raw = 'https://index.docker.io/v1/'
// Bare host without scheme
if (!/^https?:\/\//i.test(raw)) {
raw = `https://${raw}`
}
let u
try {
u = new URL(raw)
} catch {
throw new Error(`Invalid registry URL: ${serveraddress}`)
}
let host = u.hostname
const port = u.port
const hostWithPort = port ? `${host}:${port}` : host
const isDockerHub =
host === 'docker.io' ||
host === 'index.docker.io' ||
host === 'registry-1.docker.io' ||
host === 'registry.hub.docker.com' ||
raw.includes('index.docker.io')
// Registry API host for Hub is registry-1.docker.io
const registryApiHost = isDockerHub
? 'registry-1.docker.io'
: hostWithPort
const scheme = u.protocol === 'http:' ? 'http:' : 'https:'
const baseUrl = isDockerHub
? 'https://registry-1.docker.io'
: `${scheme}//${registryApiHost}`
return {
baseUrl,
host: isDockerHub ? 'docker.io' : host,
registryApiHost,
isDockerHub,
serveraddress: isDockerHub ? 'https://index.docker.io/v1/' : `${scheme}//${registryApiHost}`,
}
}
/**
* @param {string} url
* @param {{ method?: string, headers?: Record<string,string>, timeoutMs?: number, body?: string|null }} opts
* @returns {Promise<{ status: number, headers: Record<string,string>, body: string }>}
*/
export function registryHttpRequest(url, opts = {}) {
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
return new Promise((resolve, reject) => {
let u
try {
u = new URL(url)
} catch (err) {
reject(err)
return
}
const lib = u.protocol === 'http:' ? http : https
const port = u.port
? Number(u.port)
: u.protocol === 'http:'
? 80
: 443
const headers = {
Connection: 'close',
...(opts.headers || {}),
}
if (opts.body != null && headers['Content-Length'] == null) {
headers['Content-Length'] = String(Buffer.byteLength(opts.body))
}
const reqOpts = {
protocol: u.protocol,
host: u.hostname,
hostname: u.hostname,
servername: u.hostname,
port,
path: u.pathname + u.search,
method: opts.method || 'GET',
headers,
agent: false,
timeout: timeoutMs,
}
let settled = false
const finish = (err, val) => {
if (settled) return
settled = true
clearTimeout(timer)
if (err) reject(err)
else resolve(val)
}
const timer = setTimeout(() => {
try {
req.destroy(new Error('Registry request timed out'))
} catch {
// ignore
}
finish(new Error('Registry request timed out'))
}, timeoutMs)
let req
try {
req = lib.request(reqOpts, (res) => {
const chunks = []
res.on('data', (c) => chunks.push(c))
res.on('end', () => {
const headersOut = {}
for (const [k, v] of Object.entries(res.headers || {})) {
headersOut[String(k).toLowerCase()] = Array.isArray(v)
? v.join(', ')
: String(v ?? '')
}
finish(null, {
status: res.statusCode || 0,
headers: headersOut,
body: Buffer.concat(chunks).toString('utf8'),
})
})
res.on('error', (err) => finish(err))
})
} catch (err) {
finish(err)
return
}
req.on('timeout', () => {
try {
req.destroy(new Error('Registry request timed out'))
} catch {
// ignore
}
})
req.on('error', (err) => finish(err))
try {
if (typeof req.setTimeout === 'function') req.setTimeout(timeoutMs)
} catch {
// ignore
}
if (opts.body != null) req.write(opts.body)
req.end()
})
}
/**
* @param {string} header
*/
function parseWwwAuthenticate(header) {
if (!header || !/bearer/i.test(header)) return null
const params = {}
for (const m of header.matchAll(/(\w+)="([^"]*)"/g)) {
params[m[1].toLowerCase()] = m[2]
}
if (!params.realm) return null
return params
}
/**
* @param {ReturnType<typeof normalizeRegistryEndpoint>} endpoint
* @param {{ username?: string, password?: string }|null} auth
* @param {string} [wwwAuthHeader]
* @param {string} [scope]
*/
async function getBearerToken(endpoint, auth, wwwAuthHeader, scope) {
const params = parseWwwAuthenticate(wwwAuthHeader) || {}
let realm = params.realm
let service = params.service
let tokenScope = params.scope || scope
if (!realm && endpoint.isDockerHub) {
realm = 'https://auth.docker.io/token'
service = 'registry.docker.io'
}
if (!realm) return null
const u = new URL(realm)
if (service) u.searchParams.set('service', service)
if (tokenScope) u.searchParams.set('scope', tokenScope)
const headers = { Accept: 'application/json', Connection: 'close' }
if (auth?.username && auth?.password) {
headers.Authorization =
'Basic ' + Buffer.from(`${auth.username}:${auth.password}`).toString('base64')
}
const res = await registryHttpRequest(u.toString(), { headers })
if (res.status < 200 || res.status >= 300) {
throw new Error(`Token request failed (${res.status})`)
}
let json
try {
json = JSON.parse(res.body)
} catch {
throw new Error('Invalid token response')
}
return json.token || json.access_token || null
}
/**
* @param {object} opts
* @param {ReturnType<typeof normalizeRegistryEndpoint>} opts.endpoint
* @param {string} opts.path — path under registry host, starts with /
* @param {string} [opts.method]
* @param {Record<string,string>} [opts.headers]
* @param {{ username?: string, password?: string }|null} [opts.auth]
* @param {string} [opts.scope] — bearer scope if known
* @param {string|null} [opts.body]
*/
export async function registryRequest(opts) {
const { endpoint, path: apiPath, method = 'GET', auth = null, scope, body = null } = opts
const url = new URL(
apiPath.startsWith('/') ? apiPath : `/${apiPath}`,
endpoint.baseUrl.endsWith('/') ? endpoint.baseUrl : endpoint.baseUrl + '/'
).toString()
/** @type {Record<string, string>} */
const headers = {
Accept: opts.headers?.Accept || 'application/json',
...(opts.headers || {}),
}
if (auth?.username && auth?.password) {
headers.Authorization =
'Basic ' + Buffer.from(`${auth.username}:${auth.password}`).toString('base64')
}
async function once(hdrs) {
return registryHttpRequest(url, { method, headers: hdrs, body })
}
let res = await once(headers)
if (res.status === 401 || res.status === 403) {
const token = await getBearerToken(endpoint, auth, res.headers['www-authenticate'], scope)
if (token) {
const next = { ...headers, Authorization: `Bearer ${token}` }
res = await once(next)
}
}
return res
}
/**
* @param {string} [serveraddress]
* @param {{ username?: string, password?: string }|null} auth
* @param {{ n?: number, last?: string }} [paging]
*/
export async function fetchCatalog(serveraddress, auth = null, paging = {}) {
const endpoint = normalizeRegistryEndpoint(serveraddress)
const n = Math.min(1000, Math.max(1, Number(paging.n) || 100))
let path = `/v2/_catalog?n=${n}`
if (paging.last) path += `&last=${encodeURIComponent(paging.last)}`
const res = await registryRequest({
endpoint,
path,
auth,
scope: 'registry:catalog:*',
headers: { Accept: 'application/json' },
})
if (res.status === 404 || res.status === 401 || res.status === 403) {
return {
endpoint,
repositories: [],
supported: false,
error:
res.status === 404
? 'This registry does not expose _catalog (common for Docker Hub / GHCR). Open a repository by name instead.'
: `Catalog not available (${res.status}). Check credentials or open a repository by name.`,
status: res.status,
}
}
if (res.status < 200 || res.status >= 300) {
throw new Error(`Catalog failed (${res.status}): ${res.body.slice(0, 200)}`)
}
let json
try {
json = JSON.parse(res.body)
} catch {
throw new Error('Invalid catalog response')
}
const repositories = Array.isArray(json.repositories) ? json.repositories : []
// Link header pagination (RFC 5988) — extract last= if present
let nextLast = null
const link = res.headers.link || ''
const m = link.match(/[?&]last=([^&>]+)/)
if (m) nextLast = decodeURIComponent(m[1])
else if (repositories.length >= n) {
nextLast = repositories[repositories.length - 1]
}
return {
endpoint,
repositories,
supported: true,
error: null,
status: res.status,
nextLast: nextLast && repositories.length >= n ? nextLast : null,
}
}
/**
* @param {string} repository
* @param {string} [serveraddress]
* @param {{ username?: string, password?: string }|null} auth
*/
export async function fetchTags(repository, serveraddress, auth = null) {
const repo = String(repository || '').replace(/^\/+|\/+$/g, '')
if (!repo) throw new Error('repository required')
const endpoint = normalizeRegistryEndpoint(serveraddress)
// Docker Hub library images: nginx → library/nginx
let name = repo
if (endpoint.isDockerHub && !name.includes('/')) {
name = `library/${name}`
}
const res = await registryRequest({
endpoint,
path: `/v2/${name}/tags/list`,
auth,
scope: `repository:${name}:pull`,
headers: { Accept: 'application/json' },
})
if (res.status === 404) {
throw Object.assign(new Error(`Repository not found: ${name}`), { code: 'REPO_NOT_FOUND' })
}
if (res.status < 200 || res.status >= 300) {
throw new Error(`List tags failed (${res.status}): ${res.body.slice(0, 200)}`)
}
let json
try {
json = JSON.parse(res.body)
} catch {
throw new Error('Invalid tags list response')
}
const tags = Array.isArray(json.tags) ? json.tags.filter(Boolean).sort() : []
return {
endpoint,
repository: name,
name: json.name || name,
tags,
}
}
/**
* @param {string} repository
* @param {string} reference — tag or digest
* @param {string} [serveraddress]
* @param {{ username?: string, password?: string }|null} auth
*/
export async function fetchManifest(repository, reference, serveraddress, auth = null) {
const repo = String(repository || '').replace(/^\/+|\/+$/g, '')
const ref = String(reference || '').trim()
if (!repo || !ref) throw new Error('repository and reference required')
const endpoint = normalizeRegistryEndpoint(serveraddress)
let name = repo
if (endpoint.isDockerHub && !name.includes('/')) {
name = `library/${name}`
}
const res = await registryRequest({
endpoint,
path: `/v2/${name}/manifests/${encodeURIComponent(ref)}`,
auth,
scope: `repository:${name}:pull`,
headers: { Accept: MANIFEST_ACCEPT },
})
if (res.status === 404) {
throw Object.assign(new Error(`Manifest not found: ${name}:${ref}`), {
code: 'MANIFEST_NOT_FOUND',
})
}
if (res.status < 200 || res.status >= 300) {
throw new Error(`Get manifest failed (${res.status}): ${res.body.slice(0, 200)}`)
}
const digest =
res.headers['docker-content-digest'] || res.headers['oci-content-digest'] || null
let manifest = null
try {
manifest = JSON.parse(res.body)
} catch {
manifest = { raw: res.body.slice(0, 4000) }
}
/** @type {string[]} */
const childDigests = []
if (Array.isArray(manifest?.manifests)) {
for (const m of manifest.manifests) {
if (m?.digest) childDigests.push(String(m.digest))
}
}
// Approximate size from config + layers when single-arch
let sizeBytes = null
if (manifest?.config?.size) sizeBytes = Number(manifest.config.size) || 0
if (Array.isArray(manifest?.layers)) {
sizeBytes = (sizeBytes || 0) + manifest.layers.reduce((a, l) => a + (Number(l.size) || 0), 0)
}
return {
endpoint,
repository: name,
reference: ref,
digest,
mediaType: manifest?.mediaType || res.headers['content-type'] || null,
schemaVersion: manifest?.schemaVersion ?? null,
architecture: manifest?.architecture || null,
os: manifest?.os || null,
childDigests,
platformCount: Array.isArray(manifest?.manifests) ? manifest.manifests.length : null,
sizeBytes,
manifest,
}
}
/**
* Delete a tag or digest from the registry (requires delete permission).
* Tags are resolved to digest first (DELETE by tag is not in V2).
*
* @param {string} repository
* @param {string} reference — tag or sha256:…
* @param {string} [serveraddress]
* @param {{ username?: string, password?: string }|null} auth
*/
export async function deleteManifest(repository, reference, serveraddress, auth = null) {
const repo = String(repository || '').replace(/^\/+|\/+$/g, '')
const ref = String(reference || '').trim()
if (!repo || !ref) throw new Error('repository and reference required')
const endpoint = normalizeRegistryEndpoint(serveraddress)
let name = repo
if (endpoint.isDockerHub && !name.includes('/')) {
name = `library/${name}`
}
let digest = ref.startsWith('sha256:') ? ref : null
if (!digest) {
const m = await fetchManifest(name, ref, serveraddress, auth)
digest = m.digest
if (!digest) throw new Error('Could not resolve tag to digest for delete')
}
const res = await registryRequest({
endpoint,
path: `/v2/${name}/manifests/${encodeURIComponent(digest)}`,
method: 'DELETE',
auth,
scope: `repository:${name}:delete`,
headers: { Accept: MANIFEST_ACCEPT },
})
// Some registries want pull+delete scope; retry with combined if 401
if (res.status === 401 || res.status === 403) {
const retry = await registryRequest({
endpoint,
path: `/v2/${name}/manifests/${encodeURIComponent(digest)}`,
method: 'DELETE',
auth,
scope: `repository:${name}:*`,
headers: { Accept: MANIFEST_ACCEPT },
})
if (retry.status === 202 || retry.status === 200 || retry.status === 204) {
return { endpoint, repository: name, reference: ref, digest, deleted: true, status: retry.status }
}
if (retry.status === 404) {
return { endpoint, repository: name, reference: ref, digest, deleted: false, status: 404, error: 'Already gone' }
}
throw new Error(
`Delete failed (${retry.status}). Registry may disallow remote delete or credential lacks delete scope.`
)
}
if (res.status === 202 || res.status === 200 || res.status === 204) {
return { endpoint, repository: name, reference: ref, digest, deleted: true, status: res.status }
}
if (res.status === 404) {
return { endpoint, repository: name, reference: ref, digest, deleted: false, status: 404, error: 'Already gone' }
}
if (res.status === 405) {
throw new Error(
'Registry does not allow remote delete (HTTP 405). Enable delete on the registry or use its native GC tools.'
)
}
throw new Error(`Delete failed (${res.status}): ${res.body.slice(0, 200)}`)
}
/**
* Delete an entire repository by removing all tags (unique digests).
* Registry HTTP API v2 has no "DELETE /v2/<name>/" — GC of untagged blobs
* is registry-specific after manifests are gone.
*
* @param {string} repository
* @param {string} [serveraddress]
* @param {{ username?: string, password?: string }|null} auth
* @param {{ maxTags?: number }} [opts]
* @returns {Promise<object>}
*/
export async function deleteRepository(repository, serveraddress, auth = null, opts = {}) {
const repo = String(repository || '').replace(/^\/+|\/+$/g, '')
if (!repo) throw new Error('repository required')
const maxTags = Math.min(2000, Math.max(1, Number(opts.maxTags) || 500))
const listed = await fetchTags(repo, serveraddress, auth)
const tags = (listed.tags || []).slice(0, maxTags)
const name = listed.repository || repo
const endpoint = listed.endpoint
if (!tags.length) {
return {
endpoint,
repository: name,
deleted: 0,
failed: 0,
skipped: 0,
tagCount: 0,
results: [],
empty: true,
message: 'Repository has no tags (already empty or inaccessible)',
}
}
// Resolve unique digests first so multi-tag images are only DELETE'd once
/** @type {Map<string, string[]>} digest → tags */
const byDigest = new Map()
/** @type {Array<object>} */
const resolveErrors = []
for (const tag of tags) {
try {
const m = await fetchManifest(name, tag, serveraddress, auth)
const digest = m.digest
if (!digest) {
resolveErrors.push({ tag, ok: false, error: 'No digest' })
continue
}
const list = byDigest.get(digest) || []
list.push(tag)
byDigest.set(digest, list)
} catch (err) {
resolveErrors.push({ tag, ok: false, error: err.message || String(err) })
}
}
/** @type {Array<object>} */
const results = [...resolveErrors]
let deleted = 0
let failed = 0
let skipped = 0
for (const [digest, digTags] of byDigest) {
try {
const r = await deleteManifest(name, digest, serveraddress, auth)
if (r.deleted) {
deleted += 1
results.push({
digest,
tags: digTags,
ok: true,
deleted: true,
status: r.status,
})
} else {
skipped += 1
results.push({
digest,
tags: digTags,
ok: true,
deleted: false,
status: r.status,
error: r.error || 'Already gone',
})
}
} catch (err) {
failed += 1
results.push({
digest,
tags: digTags,
ok: false,
deleted: false,
error: err.message || String(err),
})
}
}
return {
endpoint,
repository: name,
deleted,
failed: failed + resolveErrors.length,
skipped,
tagCount: tags.length,
digestCount: byDigest.size,
truncated: (listed.tags || []).length > maxTags,
results,
empty: false,
}
}
/**
* Enrich tags with digests (best-effort, capped concurrency).
* @param {string} repository
* @param {string[]} tags
* @param {string} [serveraddress]
* @param {{ username?: string, password?: string }|null} auth
* @param {{ limit?: number }} [opts]
*/
export async function enrichTagsWithDigests(
repository,
tags,
serveraddress,
auth = null,
opts = {}
) {
const limit = Math.min(tags.length, Math.max(1, Number(opts.limit) || 40))
const slice = tags.slice(0, limit)
/** @type {Array<{ tag: string, digest: string|null, sizeBytes: number|null, mediaType: string|null, error?: string }>} */
const out = []
let i = 0
const workers = Math.min(4, slice.length)
async function worker() {
while (i < slice.length) {
const idx = i++
const tag = slice[idx]
try {
const m = await fetchManifest(repository, tag, serveraddress, auth)
out[idx] = {
tag,
digest: m.digest,
sizeBytes: m.sizeBytes,
mediaType: m.mediaType,
}
} catch (err) {
out[idx] = {
tag,
digest: null,
sizeBytes: null,
mediaType: null,
error: err.message,
}
}
}
}
await Promise.all(Array.from({ length: workers }, () => worker()))
return out.filter(Boolean)
}