Files
peardock/server/handlers/vault.js
T
Raven Scott 8235a8a6c8
Release rolling / release (push) Has been cancelled
Fix flatten push auth by retargeting short names to vault registry
Private registry credentials were applied while pushing Docker Hub short
names. Auto-retag under the credential host and prefix the flattener repo.
2026-07-18 07:46:07 -04:00

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' }
})
}