Fix flatten push auth by retargeting short names to vault registry
Release rolling / release (push) Has been cancelled
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.
This commit is contained in:
+13
-2
@@ -348,12 +348,23 @@ function classifyDockerMessage(message, method) {
|
||||
severity: 'danger',
|
||||
}
|
||||
}
|
||||
if (/pull access denied|unauthorized|authentication required/i.test(lower)) {
|
||||
if (/Credential is for registry|REGISTRY_HOST_MISMATCH/i.test(m)) {
|
||||
return {
|
||||
code: 'REGISTRY_HOST_MISMATCH',
|
||||
title: 'Image registry does not match credential',
|
||||
message: m,
|
||||
recovery:
|
||||
'Include the registry host in the repository (e.g. 192.168.0.12:5555/myapp) or select a credential for that host. Short names push to Docker Hub.',
|
||||
severity: 'warning',
|
||||
}
|
||||
}
|
||||
if (/pull access denied|unauthorized|authentication required|incorrect username or password/i.test(lower)) {
|
||||
return {
|
||||
code: 'DOCKER_ERROR',
|
||||
title: 'Registry authentication required',
|
||||
message: m,
|
||||
recovery: 'Log in to the registry on the host or store credentials in Vault, then retry.',
|
||||
recovery:
|
||||
'Confirm the vault credential username/password and server address. For private registries, the image must be tagged with that host (e.g. host:port/name:tag), not a short Docker Hub name.',
|
||||
severity: 'warning',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -598,6 +598,9 @@ export async function flattenContainerJob(args) {
|
||||
})
|
||||
setJobProgress(job.id, tracker.snapshot(), { stepId: 'push' })
|
||||
log(`Pushing ${ref}…`)
|
||||
if (args.credentialId) {
|
||||
log(`Using vault credential ${args.credentialId} (auto-retags short names onto that registry)`)
|
||||
}
|
||||
try {
|
||||
const body = {
|
||||
image: ref,
|
||||
@@ -611,7 +614,10 @@ export async function flattenContainerJob(args) {
|
||||
setJobProgress(final.jobId, final.snapshot, { stepId: 'push' })
|
||||
if (final.milestoneLine) appendJobLog(final.jobId, final.milestoneLine)
|
||||
}
|
||||
log(res?.message || `Pushed ${ref}`)
|
||||
if (res?.retargeted && res?.image && res.image !== ref) {
|
||||
log(`Retagged for registry: ${ref} → ${res.image}`)
|
||||
}
|
||||
log(res?.message || `Pushed ${res?.image || ref}`)
|
||||
return res
|
||||
} catch (err) {
|
||||
finalizePullProgress(trackKey, {
|
||||
|
||||
+2
-1
@@ -5600,7 +5600,8 @@ services:
|
||||
<select id="flatten-credential" class="form-select bg-dark text-white registry-cred-select">
|
||||
<option value="">No credential (public / session auto)</option>
|
||||
</select>
|
||||
<small class="text-muted">Uses vault credentials from <strong>Registry</strong>. Required for private registries.</small>
|
||||
<small class="text-muted d-block">Uses vault credentials from <strong>Registry</strong>. Selecting one prefixes the repository with that registry host so the push does not go to Docker Hub.</small>
|
||||
<div id="flatten-push-hint" class="flatten-push-hint small mt-2" hidden></div>
|
||||
</div>
|
||||
|
||||
<div class="flatten-section-label d-flex align-items-center justify-content-between mt-1">
|
||||
|
||||
+141
-2
@@ -14,6 +14,7 @@ import {
|
||||
loadAuthStatus,
|
||||
fillCredentialSelect,
|
||||
credentialIdFromSelect,
|
||||
getCachedCredentials,
|
||||
} from './registryManager.js'
|
||||
|
||||
/** @type {{ container: object|null, inspect: object|null }} */
|
||||
@@ -22,6 +23,62 @@ const state = {
|
||||
inspect: null,
|
||||
}
|
||||
|
||||
/**
|
||||
* Host[:port] from vault serveraddress for image tags (empty = Docker Hub).
|
||||
* @param {string} [serveraddress]
|
||||
*/
|
||||
function registryPrefixFromServeraddress(serveraddress) {
|
||||
let raw = String(serveraddress || '')
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/^https?:\/\//, '')
|
||||
.replace(/\/+$/, '')
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* True when first path segment looks like a registry host (has . or : or localhost).
|
||||
* @param {string} repo
|
||||
*/
|
||||
function repoHasRegistryHost(repo) {
|
||||
const first = String(repo || '').split('/')[0] || ''
|
||||
if (!first) return false
|
||||
if (first === 'localhost') return true
|
||||
return first.includes('.') || first.includes(':')
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure repo is under the credential's registry when pushing.
|
||||
* @param {string} repo
|
||||
* @param {string} [serveraddress]
|
||||
*/
|
||||
function ensureRepoUnderRegistry(repo, serveraddress) {
|
||||
const prefix = registryPrefixFromServeraddress(serveraddress)
|
||||
if (!prefix) return repo
|
||||
let name = String(repo || '').trim()
|
||||
if (!name) return `${prefix}/image`
|
||||
if (name.startsWith(`${prefix}/`)) return name
|
||||
// Replace a different registry host with the credential's host
|
||||
if (repoHasRegistryHost(name)) {
|
||||
const parts = name.split('/')
|
||||
parts[0] = prefix
|
||||
return parts.join('/')
|
||||
}
|
||||
return `${prefix}/${name}`
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} s
|
||||
*/
|
||||
@@ -103,12 +160,76 @@ function updateRefPreview() {
|
||||
}
|
||||
el.textContent = `${repo}:${tag || 'latest'}`
|
||||
el.classList.remove('text-muted')
|
||||
updatePushHint()
|
||||
}
|
||||
|
||||
/**
|
||||
* When push is on + vault credential selected, prefix repo with that registry.
|
||||
* @param {{ force?: boolean }} [opts]
|
||||
*/
|
||||
function applyCredentialRegistryPrefix(opts = {}) {
|
||||
const pushOn = document.getElementById('flatten-push')?.checked
|
||||
if (!pushOn && !opts.force) return
|
||||
const credVal = document.getElementById('flatten-credential')?.value
|
||||
const credId = credentialIdFromSelect(credVal)
|
||||
if (!credId) return
|
||||
const cred = getCachedCredentials().find((c) => c.id === credId)
|
||||
if (!cred?.serveraddress) return
|
||||
const repoEl = document.getElementById('flatten-repo')
|
||||
if (!repoEl) return
|
||||
const next = ensureRepoUnderRegistry(repoEl.value.trim(), cred.serveraddress)
|
||||
if (next && next !== repoEl.value.trim()) {
|
||||
repoEl.value = next
|
||||
updateRefPreview()
|
||||
}
|
||||
updatePushHint()
|
||||
}
|
||||
|
||||
function updatePushHint() {
|
||||
const hint = document.getElementById('flatten-push-hint')
|
||||
if (!hint) return
|
||||
const pushOn = document.getElementById('flatten-push')?.checked
|
||||
if (!pushOn) {
|
||||
hint.textContent = ''
|
||||
hint.hidden = true
|
||||
return
|
||||
}
|
||||
const credVal = document.getElementById('flatten-credential')?.value
|
||||
const credId = credentialIdFromSelect(credVal)
|
||||
const repo = document.getElementById('flatten-repo')?.value?.trim() || ''
|
||||
const tag = document.getElementById('flatten-tag')?.value?.trim() || 'latest'
|
||||
if (!credId) {
|
||||
hint.hidden = false
|
||||
hint.innerHTML =
|
||||
'<i class="fas fa-info-circle me-1"></i>No vault credential — push uses session auth or anonymous. ' +
|
||||
'Short names go to <strong>Docker Hub</strong>; include <code class="flatten-code">host:port/name</code> for a private registry.'
|
||||
return
|
||||
}
|
||||
const cred = getCachedCredentials().find((c) => c.id === credId)
|
||||
const prefix = registryPrefixFromServeraddress(cred?.serveraddress)
|
||||
const ref = repo ? `${repo}:${tag}` : '…'
|
||||
if (prefix && !repoHasRegistryHost(repo)) {
|
||||
hint.hidden = false
|
||||
hint.innerHTML =
|
||||
`<i class="fas fa-exclamation-triangle me-1 text-warning"></i>` +
|
||||
`Credential is for <strong>${escapeHtml(prefix)}</strong> but the image is a short name. ` +
|
||||
`It will be retagged to <code class="flatten-code">${escapeHtml(prefix)}/${escapeHtml(repo || '…')}:${escapeHtml(tag)}</code> on push.`
|
||||
return
|
||||
}
|
||||
hint.hidden = false
|
||||
hint.innerHTML =
|
||||
`<i class="fas fa-cloud-upload-alt me-1"></i>` +
|
||||
`Will push <code class="flatten-code">${escapeHtml(ref)}</code>` +
|
||||
(prefix ? ` to <strong>${escapeHtml(prefix)}</strong>` : ' (Docker Hub / session)') +
|
||||
` as <strong>${escapeHtml(cred?.username || 'user')}</strong>.`
|
||||
}
|
||||
|
||||
function togglePushFields() {
|
||||
const on = document.getElementById('flatten-push')?.checked
|
||||
const wrap = document.getElementById('flatten-push-fields')
|
||||
if (wrap) wrap.style.display = on ? '' : 'none'
|
||||
if (on) applyCredentialRegistryPrefix()
|
||||
updatePushHint()
|
||||
}
|
||||
|
||||
function toggleAdvanced() {
|
||||
@@ -267,7 +388,7 @@ function collectFlattenArgs() {
|
||||
const container = state.container
|
||||
if (!container?.Id) throw new Error('No container selected')
|
||||
|
||||
const repo = document.getElementById('flatten-repo')?.value?.trim() || ''
|
||||
let repo = document.getElementById('flatten-repo')?.value?.trim() || ''
|
||||
const tag = document.getElementById('flatten-tag')?.value?.trim() || 'latest'
|
||||
if (!repo) throw new Error('Repository name is required')
|
||||
|
||||
@@ -286,7 +407,21 @@ function collectFlattenArgs() {
|
||||
|
||||
const credVal = document.getElementById('flatten-credential')?.value
|
||||
const credId = credentialIdFromSelect(credVal)
|
||||
if (args.push && credId) args.credentialId = credId
|
||||
if (args.push && credId) {
|
||||
args.credentialId = credId
|
||||
// Tag the flat image under the credential's registry so push does not hit Docker Hub
|
||||
const cred = getCachedCredentials().find((c) => c.id === credId)
|
||||
if (cred?.serveraddress) {
|
||||
const under = ensureRepoUnderRegistry(repo, cred.serveraddress)
|
||||
if (under !== repo) {
|
||||
args.repo = under
|
||||
repo = under
|
||||
const repoEl = document.getElementById('flatten-repo')
|
||||
if (repoEl) repoEl.value = under
|
||||
updateRefPreview()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const advanced = document.getElementById('flatten-advanced-toggle')?.checked
|
||||
const preserve = document.getElementById('flatten-preserve-config')?.checked
|
||||
@@ -385,6 +520,10 @@ export function initContainerFlattener() {
|
||||
document.getElementById('flatten-repo')?.addEventListener('input', updateRefPreview)
|
||||
document.getElementById('flatten-tag')?.addEventListener('input', updateRefPreview)
|
||||
document.getElementById('flatten-push')?.addEventListener('change', togglePushFields)
|
||||
document.getElementById('flatten-credential')?.addEventListener('change', () => {
|
||||
applyCredentialRegistryPrefix()
|
||||
updatePushHint()
|
||||
})
|
||||
document.getElementById('flatten-advanced-toggle')?.addEventListener('change', toggleAdvanced)
|
||||
|
||||
document.getElementById('flatten-preserve-config')?.addEventListener('change', (e) => {
|
||||
|
||||
@@ -4,7 +4,16 @@
|
||||
import { docker } from '../services/docker.js'
|
||||
import * as validation from '../utils/validation.js'
|
||||
import { Pushes } from '../../shared/protocol.js'
|
||||
import { resolveRegistryAuth, isUsableDockerAuth } from './vault.js'
|
||||
import {
|
||||
resolveRegistryAuth,
|
||||
isUsableDockerAuth,
|
||||
authMatchesImage,
|
||||
retargetImageRefForRegistry,
|
||||
parseImageRepoTag,
|
||||
registryPrefixFromServeraddress,
|
||||
normalizeRegistryHost,
|
||||
registryHostFromImage,
|
||||
} from './vault.js'
|
||||
import logger from '../utils/logger.js'
|
||||
|
||||
/**
|
||||
@@ -318,6 +327,7 @@ export function registerImageHandlers(session) {
|
||||
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)
|
||||
@@ -338,6 +348,35 @@ export function registerImageHandlers(session) {
|
||||
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)
|
||||
@@ -378,6 +417,7 @@ export function registerImageHandlers(session) {
|
||||
message: `Image "${pushRef}" pushed`,
|
||||
image: pushRef,
|
||||
usedAuth: Boolean(authconfig),
|
||||
retargeted,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -81,6 +81,82 @@ export function authMatchesImage(auth, image) {
|
||||
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).
|
||||
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
normalizeRegistryHost,
|
||||
registryHostFromImage,
|
||||
resolveRegistryAuth,
|
||||
registryPrefixFromServeraddress,
|
||||
retargetImageRefForRegistry,
|
||||
parseImageRepoTag,
|
||||
} from '../server/handlers/vault.js'
|
||||
|
||||
test('isUsableDockerAuth requires username and non-empty password', (t) => {
|
||||
@@ -44,6 +47,24 @@ test('authMatchesImage only applies same-registry session auth', (t) => {
|
||||
t.ok(authMatchesImage({ serveraddress: 'https://ghcr.io' }, 'ghcr.io/a/b:1'))
|
||||
})
|
||||
|
||||
test('registryPrefixFromServeraddress strips scheme and api path, keeps port', (t) => {
|
||||
t.is(registryPrefixFromServeraddress('https://192.168.0.12:5555/v2/'), '192.168.0.12:5555')
|
||||
t.is(registryPrefixFromServeraddress('https://ghcr.io'), 'ghcr.io')
|
||||
t.is(registryPrefixFromServeraddress('https://index.docker.io/v1/'), '')
|
||||
t.is(
|
||||
retargetImageRefForRegistry('apache-httpd:flat-latest', 'https://192.168.0.12:5555/'),
|
||||
'192.168.0.12:5555/apache-httpd:flat-latest'
|
||||
)
|
||||
t.is(
|
||||
retargetImageRefForRegistry('ghcr.io/org/app:1', 'https://192.168.0.12:5555/'),
|
||||
'ghcr.io/org/app:1'
|
||||
)
|
||||
t.alike(parseImageRepoTag('192.168.0.12:5555/myapp:flat'), {
|
||||
repo: '192.168.0.12:5555/myapp',
|
||||
tag: 'flat',
|
||||
})
|
||||
})
|
||||
|
||||
test('resolveRegistryAuth returns null without session/vault (anonymous)', (t) => {
|
||||
const session = { state: new Map(), id: 'peer1' }
|
||||
const auth = resolveRegistryAuth(session, {
|
||||
|
||||
@@ -4487,6 +4487,15 @@ table.pd-col-table td.pd-col-hidden {
|
||||
background: var(--bg-surface, rgba(255, 255, 255, 0.02));
|
||||
}
|
||||
|
||||
.flatten-push-hint {
|
||||
padding: 0.5rem 0.65rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: rgba(56, 189, 248, 0.08);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Accordion bodies must stay collapsed when not .show (defensive vs BS CDN lag) */
|
||||
.accordion-collapse.collapse:not(.show) {
|
||||
display: none !important;
|
||||
|
||||
Reference in New Issue
Block a user