Release rolling / release (push) Has been cancelled
Private registry credentials were applied while pushing Docker Hub short names. Auto-retag under the credential host and prefix the flattener repo.
346 lines
11 KiB
JavaScript
346 lines
11 KiB
JavaScript
/**
|
|
* 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' }
|
|
})
|
|
}
|