/** * Registry credential vault RPC handlers. */ import * as vault from '../core/registry-vault.js' import * as validation from '../utils/validation.js' import { docker } from '../services/docker.js' import logger from '../utils/logger.js' import { getSessionAuthconfig, setSessionAuthconfig, clearSessionAuthconfig } from './system.js' /** * Auth is only usable if both username and password are non-empty. * Sending username with empty/wrong password breaks Docker Hub anonymous pulls. * @param {unknown} auth * @returns {auth is { username: string, password: string, serveraddress?: string }} */ export function isUsableDockerAuth(auth) { if (!auth || typeof auth !== 'object') return false const u = String(/** @type {any} */ (auth).username || '').trim() const p = /** @type {any} */ (auth).password return Boolean(u && p != null && String(p).length > 0) } /** * Normalize registry host for comparison (docker.io aliases). * @param {string} [hint] * @returns {string} */ export function normalizeRegistryHost(hint) { const raw = String(hint || '') .toLowerCase() .trim() .replace(/^https?:\/\//, '') .replace(/\/+$/, '') .split('/')[0] if ( !raw || raw === 'docker.io' || raw === 'index.docker.io' || raw === 'registry-1.docker.io' || raw === 'registry.hub.docker.com' || raw.includes('docker.io') ) { return 'docker.io' } return raw.split(':')[0] || raw } /** * @param {string} image * @returns {string|null} */ export function registryHostFromImage(image) { if (!image || typeof image !== 'string') return null const ref = image.split('@')[0] const withoutTag = ref.includes('/') ? ref.replace(/:[^/]+$/, '') : ref // docker.io short names: nginx, library/nginx, user/app const parts = withoutTag.split('/') if (parts.length === 1) return 'docker.io' if ( parts.length === 2 && !parts[0].includes('.') && !parts[0].includes(':') && parts[0] !== 'localhost' ) { return 'docker.io' } return parts[0].split(':')[0] || null } /** * Whether a credential's serveraddress is for the same registry as the image. * @param {{ serveraddress?: string }} auth * @param {string} [image] */ export function authMatchesImage(auth, image) { if (!image) return true const imageHost = normalizeRegistryHost(registryHostFromImage(image) || '') const authHost = normalizeRegistryHost(auth?.serveraddress || 'docker.io') return imageHost === authHost } /** * Host[:port] prefix for tagging images toward a vault serveraddress. * Empty string for Docker Hub (short names push to docker.io). * @param {string} [serveraddress] * @returns {string} */ export function registryPrefixFromServeraddress(serveraddress) { let raw = String(serveraddress || '') .toLowerCase() .trim() .replace(/^https?:\/\//, '') .replace(/\/+$/, '') // Strip API path suffixes commonly stored on credentials raw = raw.replace(/\/v2\/?$/i, '').replace(/\/v1\/?$/i, '') const hostPort = raw.split('/')[0] || '' if ( !hostPort || hostPort === 'docker.io' || hostPort === 'index.docker.io' || hostPort === 'registry-1.docker.io' || hostPort === 'registry.hub.docker.com' || hostPort.includes('docker.io') ) { return '' } return hostPort } /** * Split image ref into { repo, tag }. * @param {string} ref * @returns {{ repo: string, tag: string }} */ export function parseImageRepoTag(ref) { const s = String(ref || '').trim() if (!s) return { repo: '', tag: 'latest' } const at = s.indexOf('@') const noDigest = at >= 0 ? s.slice(0, at) : s const lastSlash = noDigest.lastIndexOf('/') const lastColon = noDigest.lastIndexOf(':') if (lastColon > lastSlash) { return { repo: noDigest.slice(0, lastColon), tag: noDigest.slice(lastColon + 1) || 'latest', } } return { repo: noDigest, tag: 'latest' } } /** * If image is a short (docker.io) name and auth targets a private registry, * return the retagged destination `registry[:port]/name:tag`. Otherwise return ref unchanged. * @param {string} imageRef * @param {string} [serveraddress] * @returns {string} */ export function retargetImageRefForRegistry(imageRef, serveraddress) { const ref = String(imageRef || '').trim() if (!ref) return ref const prefix = registryPrefixFromServeraddress(serveraddress) if (!prefix) return ref const imageHost = registryHostFromImage(ref) // Already points at a non-Hub registry — leave as-is (caller may error on mismatch) if (imageHost && imageHost !== 'docker.io') return ref const { repo, tag } = parseImageRepoTag(ref) let name = repo if (name.startsWith('docker.io/')) name = name.slice('docker.io/'.length) if (name.startsWith('library/')) name = name.slice('library/'.length) if (!name) return ref // Avoid double-prefix if user already typed the host without it looking like a registry if (name === prefix || name.startsWith(`${prefix}/`)) return `${name}:${tag}` return `${prefix}/${name}:${tag}` } /** * Resolve Docker authconfig for pull/push from vault id, inline auth, or session. * Returns null for anonymous (no usable credentials for that registry). * * @param {import('../rpc/session.js').PeerSession} session * @param {{ credentialId?: string, auth?: object, autoVault?: boolean, image?: string }} args * @returns {{ username: string, password: string, serveraddress: string }|null} */ export function resolveRegistryAuth(session, args = {}) { /** @param {object|null|undefined} c */ const usable = (c) => { if (!isUsableDockerAuth(c)) return null return { username: String(c.username).trim(), password: String(c.password), serveraddress: c.serveraddress || 'https://index.docker.io/v1/', } } if (args.credentialId) { const cred = vault.getCredential(String(args.credentialId)) if (!cred) { throw Object.assign(new Error('Vault credential not found'), { code: 'VAULT_NOT_FOUND' }) } const out = usable(cred) if (!out) { throw Object.assign(new Error('Vault credential is incomplete (missing username or password)'), { code: 'VAULT_INVALID', }) } return out } if (args.auth) { const out = usable(args.auth) if (out) { return { ...out, serveraddress: out.serveraddress || args.auth.serveraddress || 'https://index.docker.io/v1/', } } } // Session auth only if it targets the same registry as the image (or no image given) const sessionAuth = getSessionAuthconfig(session) if (sessionAuth && authMatchesImage(sessionAuth, args.image)) { const out = usable(sessionAuth) if (out) return out } // Optional: match vault entry for this image's registry host only if (args.autoVault !== false && args.image) { const host = registryHostFromImage(args.image) if (host) { const found = vault.findCredentialForServer(host) if (found && authMatchesImage(found, args.image)) { const out = usable(found) if (out) return out } } } // No usable credentials → anonymous pull/push attempt return null } async function checkDockerAuth(authconfig) { return new Promise((resolve, reject) => { docker.checkAuth(authconfig, (err, res) => (err ? reject(err) : resolve(res))) }) } export function registerVaultHandlers(session) { session.respond('listVaultCredentials', async () => { return { success: true, type: 'vaultCredentials', data: vault.listCredentials(), } }) session.respond('vaultStoreCredential', async (args) => { const username = validation.sanitizeString(args.username, 128) const password = args.password const serveraddress = validation.sanitizeString( args.serveraddress || 'https://index.docker.io/v1/', 512 ) const label = validation.sanitizeString(args.label || '', 128) if (!username || !password) throw new Error('username and password required') // Optional verify against engine if (args.verify !== false) { try { await checkDockerAuth({ username, password, serveraddress }) } catch (err) { if (args.requireAuth) throw new Error(`Auth failed: ${err.message}`) logger.warn('vault store: checkAuth soft-fail', { error: err.message }) } } const stored = vault.storeCredential({ id: args.id, username, password, serveraddress, label: label || username, }) return { success: true, message: 'Credential stored encrypted at rest', data: stored } }) session.respond('vaultDeleteCredential', async (args) => { if (!args.id) throw new Error('credential id required') return vault.deleteCredential(args.id) }) session.respond('vaultUseCredential', async (args) => { if (!args.id) throw new Error('credential id required') const listed = vault.listCredentials().find((c) => c.id === args.id) const cred = vault.getCredential(args.id) if (!cred) throw new Error('Credential not found') const auth = { username: cred.username, password: cred.password, serveraddress: cred.serveraddress, credentialId: args.id, label: listed?.label || cred.username, } setSessionAuthconfig(session, auth) return { success: true, message: `Using vault credential for ${cred.username}`, data: { id: args.id, username: cred.username, serveraddress: cred.serveraddress, label: listed?.label || null, }, } }) session.respond('vaultTestCredential', async (args) => { let authconfig = null if (args.id) { const cred = vault.getCredential(String(args.id)) if (!cred) throw new Error('Credential not found') authconfig = { username: cred.username, password: cred.password, serveraddress: cred.serveraddress, } } else { const username = validation.sanitizeString(args.username, 128) const password = args.password const serveraddress = validation.sanitizeString( args.serveraddress || 'https://index.docker.io/v1/', 512 ) if (!username || !password) throw new Error('id or username+password required') authconfig = { username, password, serveraddress } } try { const res = await checkDockerAuth(authconfig) return { success: true, ok: true, message: `Authenticated as ${authconfig.username}`, data: { username: authconfig.username, serveraddress: authconfig.serveraddress, identityToken: Boolean(res?.IdentityToken), status: res?.Status || null, }, } } catch (err) { throw Object.assign(new Error(`Auth failed: ${err.message}`), { code: 'REGISTRY_AUTH_FAILED', }) } }) session.respond('vaultClearSession', async () => { clearSessionAuthconfig(session) return { success: true, message: 'Registry session credentials cleared' } }) }