Fix anonymous registry push X-Registry-Auth EOF error
Release rolling / release (push) Successful in 7m34s
Release rolling / release (push) Successful in 7m34s
Docker requires a valid X-Registry-Auth header on push; send empty anonymous credentials for ttl.sh and other no-auth targets.
This commit is contained in:
@@ -358,6 +358,16 @@ function classifyDockerMessage(message, method) {
|
||||
severity: 'warning',
|
||||
}
|
||||
}
|
||||
if (/X-Registry-Auth|missing X-Registry-Auth/i.test(m)) {
|
||||
return {
|
||||
code: 'DOCKER_ERROR',
|
||||
title: 'Registry auth header rejected',
|
||||
message: m,
|
||||
recovery:
|
||||
'Anonymous pushes (e.g. ttl.sh) need a valid empty auth header — update peardock-server and retry. For private registries, select a vault credential.',
|
||||
severity: 'danger',
|
||||
}
|
||||
}
|
||||
if (/pull access denied|unauthorized|authentication required|incorrect username or password/i.test(lower)) {
|
||||
return {
|
||||
code: 'DOCKER_ERROR',
|
||||
|
||||
@@ -30,6 +30,22 @@ function dockerAuthOpts(authconfig) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Docker Engine requires X-Registry-Auth on image push.
|
||||
* Omitting it (or sending an empty header) yields:
|
||||
* "missing X-Registry-Auth: invalid X-Registry-Auth header: EOF"
|
||||
* Anonymous registries (ttl.sh, public Hub) need a valid empty-cred payload.
|
||||
* @returns {{ username: string, password: string, email: string, serveraddress: string }}
|
||||
*/
|
||||
export function anonymousRegistryAuth() {
|
||||
return {
|
||||
username: '',
|
||||
password: '',
|
||||
email: '',
|
||||
serveraddress: '',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} err
|
||||
* @returns {boolean}
|
||||
@@ -89,6 +105,9 @@ async function pullImageStream(session, imageName, auth) {
|
||||
* stringifies undefined as the literal "undefined", which Docker treats as a
|
||||
* real tag (e.g. repo:undefined → "tag does not exist").
|
||||
*
|
||||
* Always sets authconfig: usable credentials when present, otherwise anonymous
|
||||
* empty credentials so Docker still gets a valid X-Registry-Auth header.
|
||||
*
|
||||
* @param {{ tag?: string|null, authconfig?: object|null }} opts
|
||||
* @returns {Record<string, unknown>}
|
||||
*/
|
||||
@@ -97,7 +116,7 @@ export function buildImagePushOpts(opts = {}) {
|
||||
const tag = opts.tag != null ? String(opts.tag).trim() : ''
|
||||
if (tag) out.tag = tag
|
||||
const auth = dockerAuthOpts(opts.authconfig || null)
|
||||
if (auth) out.authconfig = auth
|
||||
out.authconfig = auth || anonymousRegistryAuth()
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import test from 'brittle'
|
||||
import { buildImagePushOpts } from '../server/handlers/images.js'
|
||||
import { buildImagePushOpts, anonymousRegistryAuth } from '../server/handlers/images.js'
|
||||
import bareQs from 'bare-querystring'
|
||||
|
||||
test('buildImagePushOpts omits undefined/empty tag', (t) => {
|
||||
t.alike(buildImagePushOpts({}), {})
|
||||
t.alike(buildImagePushOpts({ tag: null }), {})
|
||||
t.alike(buildImagePushOpts({ tag: undefined }), {})
|
||||
t.alike(buildImagePushOpts({ tag: '' }), {})
|
||||
t.alike(buildImagePushOpts({ tag: ' ' }), {})
|
||||
const ANON = anonymousRegistryAuth()
|
||||
|
||||
test('buildImagePushOpts always includes authconfig (anonymous when none)', (t) => {
|
||||
// Docker push requires X-Registry-Auth; empty/missing → EOF error
|
||||
t.alike(buildImagePushOpts({}), { authconfig: ANON })
|
||||
t.alike(buildImagePushOpts({ tag: null }), { authconfig: ANON })
|
||||
t.alike(buildImagePushOpts({ tag: undefined }), { authconfig: ANON })
|
||||
t.alike(buildImagePushOpts({ tag: '' }), { authconfig: ANON })
|
||||
t.alike(buildImagePushOpts({ tag: ' ' }), { authconfig: ANON })
|
||||
})
|
||||
|
||||
test('buildImagePushOpts includes real tags only', (t) => {
|
||||
t.alike(buildImagePushOpts({ tag: 'latest' }), { tag: 'latest' })
|
||||
t.alike(buildImagePushOpts({ tag: 'v1.2.3' }), { tag: 'v1.2.3' })
|
||||
t.alike(buildImagePushOpts({ tag: 'latest' }), { tag: 'latest', authconfig: ANON })
|
||||
t.alike(buildImagePushOpts({ tag: 'v1.2.3' }), { tag: 'v1.2.3', authconfig: ANON })
|
||||
})
|
||||
|
||||
test('buildImagePushOpts includes auth when username present', (t) => {
|
||||
@@ -32,13 +35,27 @@ test('buildImagePushOpts includes auth when username present', (t) => {
|
||||
})
|
||||
})
|
||||
|
||||
test('buildImagePushOpts skips auth without username', (t) => {
|
||||
t.alike(buildImagePushOpts({ authconfig: { password: 'x' } }), {})
|
||||
test('buildImagePushOpts uses anonymous auth without username', (t) => {
|
||||
t.alike(buildImagePushOpts({ authconfig: { password: 'x' } }), { authconfig: ANON })
|
||||
})
|
||||
|
||||
test('buildImagePushOpts skips auth without password (anonymous pull path)', (t) => {
|
||||
t.alike(buildImagePushOpts({ authconfig: { username: 'u', password: '' } }), {})
|
||||
t.alike(buildImagePushOpts({ authconfig: { username: 'u', password: null } }), {})
|
||||
test('buildImagePushOpts uses anonymous auth without password', (t) => {
|
||||
t.alike(buildImagePushOpts({ authconfig: { username: 'u', password: '' } }), {
|
||||
authconfig: ANON,
|
||||
})
|
||||
t.alike(buildImagePushOpts({ authconfig: { username: 'u', password: null } }), {
|
||||
authconfig: ANON,
|
||||
})
|
||||
})
|
||||
|
||||
test('anonymousRegistryAuth produces valid empty credentials for X-Registry-Auth', (t) => {
|
||||
const a = anonymousRegistryAuth()
|
||||
t.is(a.username, '')
|
||||
t.is(a.password, '')
|
||||
// Must JSON-serialize to a non-empty base64 payload (not EOF)
|
||||
const b64 = Buffer.from(JSON.stringify(a)).toString('base64')
|
||||
t.ok(b64.length > 0)
|
||||
t.ok(JSON.parse(Buffer.from(b64, 'base64').toString('utf8')))
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -49,7 +66,9 @@ test('buildImagePushOpts skips auth without password (anonymous pull path)', (t)
|
||||
test('bare-querystring turns undefined into literal "undefined" (root cause)', (t) => {
|
||||
t.is(bareQs.stringify({ tag: undefined }), 'tag=undefined')
|
||||
t.is(bareQs.stringify({ tag: null }), 'tag=null')
|
||||
// Correct pattern: omit the key entirely
|
||||
t.is(bareQs.stringify(buildImagePushOpts({ tag: undefined })), '')
|
||||
t.is(bareQs.stringify(buildImagePushOpts({ tag: 'latest' })), 'tag=latest')
|
||||
// Correct pattern: omit the tag key entirely (authconfig is an object, not query)
|
||||
const noTag = buildImagePushOpts({ tag: undefined })
|
||||
t.absent(Object.prototype.hasOwnProperty.call(noTag, 'tag'))
|
||||
t.ok(noTag.authconfig)
|
||||
t.is(bareQs.stringify({ tag: 'latest' }), 'tag=latest')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user