/** * Image RPC handlers. */ import { docker } from '../services/docker.js' import { peers } from '../core/peer-registry.js' import * as validation from '../utils/validation.js' import { Pushes } from '../../shared/protocol.js' import { resolveRegistryAuth, isUsableDockerAuth, authMatchesImage, retargetImageRefForRegistry, parseImageRepoTag, registryPrefixFromServeraddress, normalizeRegistryHost, registryHostFromImage, } from './vault.js' import logger from '../utils/logger.js' /** * @param {object|null} authconfig * @returns {object|undefined} */ function dockerAuthOpts(authconfig) { // Incomplete auth (e.g. username without password) breaks Docker Hub anonymous pulls if (!isUsableDockerAuth(authconfig)) return undefined return { username: String(authconfig.username).trim(), password: String(authconfig.password), serveraddress: authconfig.serveraddress || 'https://index.docker.io/v1/', } } /** * Docker Engine requires X-Registry-Auth on image push. * Omitting it (or sending an empty header) yields: * "missing X-Registry-Auth: invalid X-Registry-Auth header: EOF" * Anonymous registries (ttl.sh, public Hub) need a valid empty-cred payload. * @returns {{ username: string, password: string, email: string, serveraddress: string }} */ export function anonymousRegistryAuth() { return { username: '', password: '', email: '', serveraddress: '', } } /** * @param {unknown} err * @returns {boolean} */ function isRegistryAuthFailure(err) { const msg = String(err?.message || err || '') const status = Number(err?.statusCode || err?.status || 0) if (status === 401 || status === 403) return true return /unauthorized|authentication required|incorrect username or password|denied:\s*requested access|needs?\s*authentication|toomanyrequests/i.test( msg ) } /** * Pull an image with optional auth. Progress events pushed to the session. * @param {import('../rpc/session.js').PeerSession} session * @param {string} imageName * @param {object|undefined} auth — dockerode authconfig or undefined for anonymous */ async function pullImageStream(session, imageName, auth) { const pullStream = await new Promise((resolve, reject) => { const onPull = (err, stream) => (err ? reject(err) : resolve(stream)) if (auth) { docker.pull(imageName, {}, onPull, auth) } else { docker.pull(imageName, onPull) } }) await new Promise((resolve, reject) => { docker.modem.followProgress( pullStream, (err) => (err ? reject(err) : resolve()), (event) => { try { session.push(Pushes.pullProgress, { type: 'pullProgress', image: imageName, status: event.status || null, progress: event.progress || null, progressDetail: event.progressDetail || null, id: event.id || null, error: event.error || null, }) } catch (e) { logger.debug('pull progress push failed', { error: e.message }) } } ) }) } /** * Build dockerode image.push() options. * * Never includes keys with undefined values. Under Bare, bare-querystring * stringifies undefined as the literal "undefined", which Docker treats as a * real tag (e.g. repo:undefined → "tag does not exist"). * * Always sets authconfig: usable credentials when present, otherwise anonymous * empty credentials so Docker still gets a valid X-Registry-Auth header. * * @param {{ tag?: string|null, authconfig?: object|null }} opts * @returns {Record} */ export function buildImagePushOpts(opts = {}) { const out = {} const tag = opts.tag != null ? String(opts.tag).trim() : '' if (tag) out.tag = tag const auth = dockerAuthOpts(opts.authconfig || null) out.authconfig = auth || anonymousRegistryAuth() return out } /** * List Docker images with per-image container usage attached. * Shared by listImages RPC and live event broadcasts so Used/Unused filters stay correct. * @param {object} [opts] * @returns {Promise} */ export async function listImagesWithUsage(opts = {}) { const listOpts = { all: opts.all !== false } if (opts.filters) listOpts.filters = opts.filters if (opts.dangling === true) { listOpts.filters = { ...(listOpts.filters || {}), dangling: ['true'] } } if (opts.reference) { listOpts.filters = { ...(listOpts.filters || {}), reference: [String(opts.reference)], } } const images = await docker.listImages(listOpts) const containers = await docker.listContainers({ all: true }) const imageUsage = {} for (const container of containers) { const imageId = container.ImageID if (!imageUsage[imageId]) imageUsage[imageId] = [] imageUsage[imageId].push({ id: container.Id, name: container.Names[0]?.replace(/^\//, '') || container.Id.substring(0, 12), state: container.State, }) } return images.map((image) => ({ ...image, usage: imageUsage[image.Id] || [], })) } export async function broadcastImages() { try { const images = await listImagesWithUsage({ all: true }) peers.broadcast(Pushes.images, { type: 'images', data: images }) } catch (err) { logger.error('Failed to broadcast images', { error: err.message }) } } export function registerImageHandlers(session) { session.respond('listImages', async (args = {}) => { let imagesWithUsage = await listImagesWithUsage(args) const total = imagesWithUsage.length const offset = Math.max(0, Number(args.offset) || 0) const limit = args.limit != null ? Math.min(Number(args.limit) || 50, 1000) : null if (limit != null) { imagesWithUsage = imagesWithUsage.slice(offset, offset + limit) } else if (offset > 0) { imagesWithUsage = imagesWithUsage.slice(offset) } return { type: 'images', data: imagesWithUsage, total, offset, limit: limit ?? imagesWithUsage.length, hasMore: limit != null ? offset + limit < total : false, } }) session.respond('pullImage', async (args) => { const imageName = validation.sanitizeString(args.image, 255) if (!imageName || !validation.isValidImageName(imageName)) { throw new Error('Invalid image name') } // Explicit credential/auth from client must not fall back to anonymous on failure const explicitAuth = Boolean( args.credentialId || (args.auth && args.auth.username && args.auth.password) ) const authconfig = resolveRegistryAuth(session, { credentialId: args.credentialId, auth: args.auth, autoVault: args.autoVault, image: imageName, }) let auth = dockerAuthOpts(authconfig) let anonymousFallback = false try { await pullImageStream(session, imageName, auth) } catch (err) { // Ambient session/vault auth often breaks public Docker Hub pulls when // credentials are stale. Retry once with no auth when pull wasn't explicit. if (auth && !explicitAuth && isRegistryAuthFailure(err)) { logger.warn('pullImage: auth failed, retrying anonymously', { image: imageName, error: err.message, serveraddress: auth.serveraddress, }) await pullImageStream(session, imageName, undefined) auth = undefined anonymousFallback = true } else { throw err } } return { success: true, message: `Image "${imageName}" pulled successfully`, image: imageName, usedAuth: Boolean(auth), anonymousFallback, } }) session.respond('removeImage', async (args) => { await docker.getImage(args.id).remove({ force: args.force || false }) return { success: true, message: `Image ${args.id} removed` } }) session.respond('inspectImage', async (args) => { const imageData = await docker.getImage(args.id).inspect() return { type: 'imageConfig', data: imageData } }) session.respond('imageHistory', async (args) => { const history = await docker.getImage(args.id).history() return { success: true, type: 'imageHistory', id: args.id, data: history } }) session.respond('searchImages', async (args) => { const term = validation.sanitizeString(args.term || args.q || '', 128) if (!term) throw new Error('Search term required') const limit = Math.min(Number(args.limit) || 25, 100) const results = await docker.searchImages({ term, limit }) return { success: true, type: 'imageSearch', term, data: results } }) session.respond('pruneImages', async (args) => { const opts = {} if (args.filters) opts.filters = args.filters // dangling only by default unless force-all if (args.all) { opts.filters = { ...(opts.filters || {}), dangling: { false: true } } } const result = await docker.pruneImages(opts) return { success: true, type: 'pruneImages', message: 'Unused images pruned', data: result, } }) session.respond('tagImage', async (args) => { const repo = validation.sanitizeString(args.repo, 255) const tag = validation.sanitizeString(args.tag || 'latest', 128) if (!repo) throw new Error('Repository name required') await docker.getImage(args.id).tag({ repo, tag }) return { success: true, message: `Image tagged as ${repo}:${tag}` } }) session.respond('buildImage', async (args) => { const { dockerfile, tag } = args if (!dockerfile) throw new Error('Dockerfile content required') const DockerfileBuffer = Buffer.from(dockerfile) const tarHeader = Buffer.alloc(512) tarHeader.write('Dockerfile', 0) tarHeader.write('100644', 156, 6) const sizeOctal = DockerfileBuffer.length.toString(8).padStart(11, '0') + '\0' tarHeader.write(sizeOctal, 124, 12) let checksum = 0 for (let i = 0; i < 512; i++) { checksum += i >= 148 && i < 156 ? 32 : tarHeader[i] } tarHeader.write(checksum.toString(8).padStart(6, '0') + '\0 ', 148) const padding = (512 - (DockerfileBuffer.length % 512)) % 512 const tarData = Buffer.concat([ tarHeader, DockerfileBuffer, Buffer.alloc(padding), Buffer.alloc(1024), ]) const buildOptions = tag ? { dockerfile: 'Dockerfile', t: tag } : { dockerfile: 'Dockerfile' } if (args.nocache) buildOptions.nocache = true if (args.pull) buildOptions.pull = true if (args.buildargs && typeof args.buildargs === 'object') { buildOptions.buildargs = args.buildargs } const buildStream = await docker.buildImage(tarData, buildOptions) let buildOutput = '' await new Promise((resolve, reject) => { docker.modem.followProgress( buildStream, (err, output) => { if (err) reject(err) else { if (output) buildOutput = output.map((o) => o.stream || o.status || '').join('') resolve(output) } }, (event) => { const line = (event.stream || event.status || event.error || '').toString() if (line) { try { session.push(Pushes.buildProgress, { type: 'buildProgress', tag: tag || null, stream: event.stream || null, status: event.status || null, error: event.error || null, aux: event.aux || null, }) } catch (e) { logger.debug('build progress push failed', { error: e.message }) } } } ) }) return { success: true, message: `Image built successfully: ${tag || 'untagged:latest'}`, output: buildOutput, } }) // —— Binary image transfer (chunked base64 over RPC) —— session.respond('pushImage', async (args) => { const imageName = validation.sanitizeString(args.image || args.id, 255) if (!imageName) throw new Error('image name/id required') // Optional: retag before push (repo:tag destination) let pushRef = imageName /** Explicit tag query only when set — never pass undefined (see below). */ let pushTag = null let retargeted = false if (args.repo) { const repo = validation.sanitizeString(args.repo, 255) const tag = validation.sanitizeString(args.tag || 'latest', 128) if (!repo) throw new Error('repo required when tagging for push') if (!tag) throw new Error('tag required when tagging for push') await docker.getImage(args.id || imageName).tag({ repo, tag }) pushRef = `${repo}:${tag}` // Name already includes the tag; omit tag query param. pushTag = null } else if (args.tag) { pushTag = validation.sanitizeString(args.tag, 128) || null } const authconfig = resolveRegistryAuth(session, { credentialId: args.credentialId, auth: args.auth, autoVault: args.autoVault, image: pushRef, }) // Short name + private registry credential → auto-retag under that registry. // Otherwise Docker pushes to docker.io with the private username/password → 401. if (authconfig && !authMatchesImage(authconfig, pushRef)) { const desired = retargetImageRefForRegistry(pushRef, authconfig.serveraddress) if (desired !== pushRef) { const { repo, tag } = parseImageRepoTag(desired) await docker.getImage(args.id || imageName || pushRef).tag({ repo, tag }) logger.info('pushImage: retagged short name for credential registry', { from: pushRef, to: desired, serveraddress: authconfig.serveraddress, }) pushRef = desired pushTag = null retargeted = true } else { const authHost = normalizeRegistryHost(authconfig.serveraddress) const imageHost = registryHostFromImage(pushRef) || 'unknown' const prefix = registryPrefixFromServeraddress(authconfig.serveraddress) throw Object.assign( new Error( `Credential is for registry "${authHost}" but image "${pushRef}" targets "${imageHost}". ` + `Retag to ${prefix ? `${prefix}/` : ''}your/image:tag before pushing, or pick a matching credential.` ), { code: 'REGISTRY_HOST_MISMATCH' } ) } } const pushOpts = buildImagePushOpts({ tag: pushTag, authconfig }) const image = docker.getImage(pushRef) const stream = await image.push(pushOpts) // Docker may return HTTP 200 and stream { error } in the body — treat as failure. let streamError = null await new Promise((resolve, reject) => { docker.modem.followProgress( stream, (err) => { if (err) reject(err) else if (streamError) reject(new Error(streamError)) else resolve() }, (event) => { try { if (event?.error) { streamError = String(event.error) logger.debug('push layer error event', { error: event.error, image: pushRef }) } session.push(Pushes.pushProgress, { type: 'pushProgress', image: pushRef, status: event.status || null, progress: event.progress || null, progressDetail: event.progressDetail || null, id: event.id || null, error: event.error || null, }) } catch (e) { logger.debug('push progress failed', { error: e.message }) } } ) }) return { success: true, message: `Image "${pushRef}" pushed`, image: pushRef, usedAuth: Boolean(authconfig), retargeted, } }) session.respond('saveImage', async (args) => { const id = args.id if (!id) throw new Error('Image id required') const maxBytes = Math.min(Number(args.maxBytes) || 50 * 1024 * 1024, 100 * 1024 * 1024) const chunkSize = Math.min(Number(args.chunkSize) || 256 * 1024, 512 * 1024) const stream = await docker.getImage(id).get() const transferId = `save-${id.slice(0, 12)}-${Date.now()}` const chunks = [] let total = 0 let index = 0 await new Promise((resolve, reject) => { stream.on('data', (chunk) => { total += chunk.length if (total > maxBytes) { stream.destroy() reject(new Error(`Image exceeds maxBytes (${maxBytes}). Use smaller image or raise maxBytes.`)) return } chunks.push(chunk) }) stream.on('end', resolve) stream.on('error', reject) }) const buf = Buffer.concat(chunks) // Stream chunks to client via push, also return first chunk summary for (let offset = 0; offset < buf.length; offset += chunkSize) { const slice = buf.subarray(offset, Math.min(offset + chunkSize, buf.length)) const done = offset + chunkSize >= buf.length session.push(Pushes.binaryChunk, { type: 'binaryChunk', transferId, kind: 'imageSave', imageId: id, index, totalBytes: buf.length, encoding: 'base64', data: slice.toString('base64'), done, }) index += 1 } // Small images: also return inline for convenience if (buf.length <= 2 * 1024 * 1024) { return { success: true, type: 'imageSave', transferId, id, encoding: 'base64', size: buf.length, chunks: index, data: buf.toString('base64'), } } return { success: true, type: 'imageSave', transferId, id, encoding: 'base64', size: buf.length, chunks: index, data: null, note: 'Large image delivered via push:binaryChunk', } }) session.respond('loadImageStart', async () => { const transferId = `load-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` session.state.set(`load:${transferId}`, { chunks: [], total: 0, startedAt: Date.now() }) return { success: true, transferId } }) session.respond('loadImageChunk', async (args) => { const transferId = args.transferId const entry = session.state.get(`load:${transferId}`) if (!entry) throw new Error('Unknown transferId; call loadImageStart first') if (!args.data) throw new Error('chunk data required') const buf = Buffer.from(args.data, args.encoding === 'utf8' ? 'utf8' : 'base64') const maxTotal = 100 * 1024 * 1024 entry.total += buf.length if (entry.total > maxTotal) { session.state.delete(`load:${transferId}`) throw new Error(`Load exceeds ${maxTotal} bytes`) } entry.chunks.push(buf) return { success: true, transferId, received: entry.total, chunks: entry.chunks.length } }) session.respond('loadImageFinish', async (args) => { const transferId = args.transferId const entry = session.state.get(`load:${transferId}`) if (!entry) throw new Error('Unknown transferId') session.state.delete(`load:${transferId}`) const buf = Buffer.concat(entry.chunks) if (!buf.length) throw new Error('No image data received') const stream = await docker.loadImage(buf) let output = '' await new Promise((resolve, reject) => { docker.modem.followProgress( stream, (err, res) => { if (err) reject(err) else { if (res) output = JSON.stringify(res) resolve(res) } }, () => {} ) }) return { success: true, message: 'Image loaded into Docker engine', size: buf.length, output, } }) session.respond('importImage', async (args) => { // Import raw rootfs tarball as a new image if (!args.data) throw new Error('base64 tar data required') const buf = Buffer.from(args.data, args.encoding === 'utf8' ? 'utf8' : 'base64') const maxBytes = 100 * 1024 * 1024 if (buf.length > maxBytes) throw new Error(`Import exceeds ${maxBytes} bytes`) const opts = {} if (args.repo) opts.repo = validation.sanitizeString(args.repo, 255) if (args.tag) opts.tag = validation.sanitizeString(args.tag, 128) if (args.message) opts.message = validation.sanitizeString(args.message, 500) const stream = await docker.importImage(buf, opts) let output = '' await new Promise((resolve, reject) => { docker.modem.followProgress( stream, (err, res) => { if (err) reject(err) else { if (res) output = JSON.stringify(res) resolve(res) } }, () => {} ) }) return { success: true, message: `Image imported${opts.repo ? ` as ${opts.repo}:${opts.tag || 'latest'}` : ''}`, size: buf.length, output, } }) session.respond('pruneBuilder', async () => { // dockerode may expose pruneBuilder; fall back to modem dial let result if (typeof docker.pruneBuilder === 'function') { result = await docker.pruneBuilder() } else { result = await new Promise((resolve, reject) => { docker.modem.dial( { path: '/build/prune', method: 'POST', statusCodes: { 200: true, 500: 'fatal' } }, (err, data) => (err ? reject(err) : resolve(data)) ) }) } return { success: true, type: 'pruneBuilder', message: 'Build cache pruned', data: result } }) }