Fix anonymous registry push X-Registry-Auth EOF error
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:
Raven Scott
2026-07-18 08:07:56 -04:00
parent 7b5da2da86
commit dff35affb7
3 changed files with 66 additions and 18 deletions
+10
View File
@@ -358,6 +358,16 @@ function classifyDockerMessage(message, method) {
severity: 'warning', 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)) { if (/pull access denied|unauthorized|authentication required|incorrect username or password/i.test(lower)) {
return { return {
code: 'DOCKER_ERROR', code: 'DOCKER_ERROR',
+20 -1
View File
@@ -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 * @param {unknown} err
* @returns {boolean} * @returns {boolean}
@@ -89,6 +105,9 @@ async function pullImageStream(session, imageName, auth) {
* stringifies undefined as the literal "undefined", which Docker treats as a * stringifies undefined as the literal "undefined", which Docker treats as a
* real tag (e.g. repo:undefined → "tag does not exist"). * 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 * @param {{ tag?: string|null, authconfig?: object|null }} opts
* @returns {Record<string, unknown>} * @returns {Record<string, unknown>}
*/ */
@@ -97,7 +116,7 @@ export function buildImagePushOpts(opts = {}) {
const tag = opts.tag != null ? String(opts.tag).trim() : '' const tag = opts.tag != null ? String(opts.tag).trim() : ''
if (tag) out.tag = tag if (tag) out.tag = tag
const auth = dockerAuthOpts(opts.authconfig || null) const auth = dockerAuthOpts(opts.authconfig || null)
if (auth) out.authconfig = auth out.authconfig = auth || anonymousRegistryAuth()
return out return out
} }
+36 -17
View File
@@ -1,18 +1,21 @@
import test from 'brittle' import test from 'brittle'
import { buildImagePushOpts } from '../server/handlers/images.js' import { buildImagePushOpts, anonymousRegistryAuth } from '../server/handlers/images.js'
import bareQs from 'bare-querystring' import bareQs from 'bare-querystring'
test('buildImagePushOpts omits undefined/empty tag', (t) => { const ANON = anonymousRegistryAuth()
t.alike(buildImagePushOpts({}), {})
t.alike(buildImagePushOpts({ tag: null }), {}) test('buildImagePushOpts always includes authconfig (anonymous when none)', (t) => {
t.alike(buildImagePushOpts({ tag: undefined }), {}) // Docker push requires X-Registry-Auth; empty/missing → EOF error
t.alike(buildImagePushOpts({ tag: '' }), {}) t.alike(buildImagePushOpts({}), { authconfig: ANON })
t.alike(buildImagePushOpts({ tag: ' ' }), {}) 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) => { test('buildImagePushOpts includes real tags only', (t) => {
t.alike(buildImagePushOpts({ tag: 'latest' }), { tag: 'latest' }) t.alike(buildImagePushOpts({ tag: 'latest' }), { tag: 'latest', authconfig: ANON })
t.alike(buildImagePushOpts({ tag: 'v1.2.3' }), { tag: 'v1.2.3' }) t.alike(buildImagePushOpts({ tag: 'v1.2.3' }), { tag: 'v1.2.3', authconfig: ANON })
}) })
test('buildImagePushOpts includes auth when username present', (t) => { 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) => { test('buildImagePushOpts uses anonymous auth without username', (t) => {
t.alike(buildImagePushOpts({ authconfig: { password: 'x' } }), {}) t.alike(buildImagePushOpts({ authconfig: { password: 'x' } }), { authconfig: ANON })
}) })
test('buildImagePushOpts skips auth without password (anonymous pull path)', (t) => { test('buildImagePushOpts uses anonymous auth without password', (t) => {
t.alike(buildImagePushOpts({ authconfig: { username: 'u', password: '' } }), {}) t.alike(buildImagePushOpts({ authconfig: { username: 'u', password: '' } }), {
t.alike(buildImagePushOpts({ authconfig: { username: 'u', password: null } }), {}) 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) => { test('bare-querystring turns undefined into literal "undefined" (root cause)', (t) => {
t.is(bareQs.stringify({ tag: undefined }), 'tag=undefined') t.is(bareQs.stringify({ tag: undefined }), 'tag=undefined')
t.is(bareQs.stringify({ tag: null }), 'tag=null') t.is(bareQs.stringify({ tag: null }), 'tag=null')
// Correct pattern: omit the key entirely // Correct pattern: omit the tag key entirely (authconfig is an object, not query)
t.is(bareQs.stringify(buildImagePushOpts({ tag: undefined })), '') const noTag = buildImagePushOpts({ tag: undefined })
t.is(bareQs.stringify(buildImagePushOpts({ tag: 'latest' })), 'tag=latest') t.absent(Object.prototype.hasOwnProperty.call(noTag, 'tag'))
t.ok(noTag.authconfig)
t.is(bareQs.stringify({ tag: 'latest' }), 'tag=latest')
}) })