Fix flatten push auth by retargeting short names to vault registry
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:
Raven Scott
2026-07-18 07:46:07 -04:00
parent e9fb26153a
commit 8235a8a6c8
8 changed files with 310 additions and 7 deletions
+13 -2
View File
@@ -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',
}
}
+7 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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) => {
+41 -1
View File
@@ -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,
}
})
+76
View File
@@ -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).
+21
View File
@@ -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, {
+9
View File
@@ -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;