This commit is contained in:
jarek committed 2026-06-15 14:56:51 +02:00
1 parent d465ecfe96
commit 83c3a5ea09
85 files changed
+10118 -1046

No files matched your search

+157 -17
View File
@@ -15,6 +15,7 @@ import { createHash } from 'node:crypto';
import type { Environment } from './db';
import { getStackEnvVarsAsRecord } from './db';
import { getAdditionalVolumeBinds } from './mount-dedupe';
import { encodeRegistryAuth } from './registry-auth';
import { isSystemContainer } from './scheduler/tasks/update-utils';
import { deepDiff } from '../utils/diff.js';
@@ -286,7 +287,7 @@ const envCache = new Map<number, CachedEnv>();
const CACHE_TTL = 30 * 60 * 1000;
// All known Docker Hub hostname variations for credential matching
const DOCKER_HUB_HOSTS = new Set([
export const DOCKER_HUB_HOSTS = new Set([
'docker.io', 'hub.docker.com', 'registry.hub.docker.com',
'index.docker.io', 'registry-1.docker.io', 'registry.docker.io', 'docker.com'
]);
@@ -1215,6 +1216,61 @@ export async function renameContainer(id: string, newName: string, envId?: numbe
await assertDockerResponse(response);
}
/**
* In-place container property update. Wraps Docker's POST /containers/{id}/update,
* which is the only API that changes container properties WITHOUT recreating it.
*
* The accepted body field set is fixed by Docker — anything outside this set
* requires container recreation (image, env, ports, networks, mounts, etc).
* Whitelist enforced here so callers can't accidentally smuggle a
* recreate-only field through this code path.
*/
export const IN_PLACE_UPDATE_FIELDS = [
// Restart policy — the headline use case (#1153)
'RestartPolicy',
// CPU
'CpuShares', 'CpuPeriod', 'CpuQuota', 'CpuRealtimePeriod', 'CpuRealtimeRuntime',
'CpusetCpus', 'CpusetMems', 'NanoCpus',
// Memory
'Memory', 'MemorySwap', 'MemoryReservation', 'MemorySwappiness', 'KernelMemory',
// Block I/O
'BlkioWeight', 'BlkioWeightDevice',
'BlkioDeviceReadBps', 'BlkioDeviceWriteBps',
'BlkioDeviceReadIOps', 'BlkioDeviceWriteIOps',
// Misc
'PidsLimit'
] as const;
export type InPlaceUpdateField = typeof IN_PLACE_UPDATE_FIELDS[number];
export interface UpdateContainerRuntimeResult {
Warnings: string[] | null;
}
/**
* Apply an in-place update. `updates` keys MUST be from IN_PLACE_UPDATE_FIELDS;
* unknown keys are silently dropped (not passed to Docker) so a malicious or
* confused caller can't sneak a recreate-only field through.
*/
export async function updateContainerRuntime(
id: string,
updates: Partial<Record<InPlaceUpdateField, unknown>>,
envId?: number | null
): Promise<UpdateContainerRuntimeResult> {
const allowed = new Set<string>(IN_PLACE_UPDATE_FIELDS);
const body: Record<string, unknown> = {};
for (const [key, value] of Object.entries(updates)) {
if (allowed.has(key) && value !== undefined) body[key] = value;
}
if (Object.keys(body).length === 0) {
throw new Error('No updatable fields provided');
}
return dockerJsonRequest<UpdateContainerRuntimeResult>(
`/containers/${id}/update`,
{ method: 'POST', body: JSON.stringify(body) },
envId
);
}
export async function getContainerLogs(id: string, tail: number | 'all' = 100, envId?: number | null, since?: string, until?: string): Promise<string> {
// Check if container has TTY enabled
const info = await inspectContainer(id, envId);
@@ -2553,6 +2609,28 @@ export async function listImages(envId?: number | null): Promise<ImageInfo[]> {
}));
}
/**
* Diagnostic log for registry auth headers (#1105).
* Logs lengths and boundary char codes — never the plaintext credentials.
* Header prefix is safe to log: it encodes the start of `{"username":"...`.
*/
function logAuthDiagnostics(
tag: string,
registry: string,
serveraddress: string,
username: string,
password: string,
authHeader: string
): void {
const userLast = username.length ? username.charCodeAt(username.length - 1).toString(16) : 'na';
const pwLast = password.length ? password.charCodeAt(password.length - 1).toString(16) : 'na';
console.log(
`${tag} auth: registry=${registry} user(len=${username.length},last=0x${userLast}) ` +
`pw(len=${password.length},last=0x${pwLast}) serveraddress=${serveraddress} ` +
`authHeader(len=${authHeader.length},prefix=${authHeader.slice(0, 16)})`
);
}
/**
* Build X-Registry-Auth header for authenticated Docker image pulls.
* Looks up stored registry credentials and returns a headers object
@@ -2566,16 +2644,19 @@ export async function buildRegistryAuthHeader(imageName: string): Promise<Record
if (creds) {
// Docker Engine requires 'https://index.docker.io/v1/' as serveraddress
// for Docker Hub auth — just the hostname is treated as unauthenticated
const serveraddress = DOCKER_HUB_HOSTS.has(registry)
? 'https://index.docker.io/v1/'
: registry;
console.log(`[Pull] Using credentials for ${serveraddress} (user: ${creds.username})`);
const isHub = DOCKER_HUB_HOSTS.has(registry);
const serveraddress = isHub ? 'https://index.docker.io/v1/' : registry;
if (isHub) {
console.log(`[Registry] docker-hub variant '${registry}' canonicalized to https://index.docker.io/v1/ for auth`);
}
const authConfig = {
username: creds.username,
password: creds.password,
serveraddress
};
headers['X-Registry-Auth'] = Buffer.from(JSON.stringify(authConfig)).toString('base64');
const authHeader = encodeRegistryAuth(authConfig);
headers['X-Registry-Auth'] = authHeader;
logAuthDiagnostics('[Pull]', registry, serveraddress, creds.username, creds.password, authHeader);
} else {
console.log(`[Pull] No credentials found for ${registry}`);
}
@@ -2618,11 +2699,19 @@ export async function pullImage(imageName: string, onProgress?: (data: any) => v
// Look up registry credentials for authenticated pulls
const headers = await buildRegistryAuthHeader(imageName);
// Diagnostic logging (#1105): what we're sending to the daemon
console.log(`[Pull] POST ${url} headers=${Object.keys(headers).join(',') || '(none)'}`);
// Use streaming: true for longer timeout on edge environments
const response = await dockerFetch(url, { method: 'POST', streaming: true, headers }, envId);
// Diagnostic logging (#1105): daemon response status
console.log(`[Pull] response status=${response.status} ${response.statusText}`);
if (!response.ok) {
throw new Error(`Failed to pull image: ${await response.text()}`);
const body = await response.text();
console.error(`[Pull] error body: ${body}`);
throw new Error(`Failed to pull image: ${body}`);
}
// Stream the response for progress updates
@@ -2644,6 +2733,9 @@ export async function pullImage(imageName: string, onProgress?: (data: any) => v
if (line.trim()) {
try {
const data = JSON.parse(line);
if (data.error || data.errorDetail) {
console.error(`[Pull] stream error: ${line}`);
}
if (onProgress) onProgress(data);
} catch {
// Ignore parse errors
@@ -2780,7 +2872,10 @@ async function findRegistryCredentials(registryHost: string): Promise<{ username
if (stored.fullRegistry === requested.fullRegistry ||
(stored.host === requested.host && !stored.path)) {
if (reg.username && reg.password) {
return { username: reg.username, password: reg.password };
const via = stored.fullRegistry === requested.fullRegistry ? 'full' : 'host-only';
console.log(`[Registry] matched stored=${reg.url} requested=${registryHost} via=${via}`);
// Normalize legacy creds saved before #1105 trim fix
return { username: reg.username.trim(), password: reg.password.trim() };
}
}
}
@@ -2792,12 +2887,20 @@ async function findRegistryCredentials(registryHost: string): Promise<{ username
const stored = parseRegistryUrl(reg.url);
if (DOCKER_HUB_HOSTS.has(stored.host)) {
if (reg.username && reg.password) {
return { username: reg.username, password: reg.password };
console.log(`[Registry] matched stored=${reg.url} requested=${registryHost} via=hub-alias`);
return { username: reg.username.trim(), password: reg.password.trim() };
}
}
}
}
// No match — log what we tried so support cases are diagnosable
const candidates = registries.map(r => parseRegistryUrl(r.url).host).join(', ');
console.log(
`[Registry] no match for requested=${registryHost} ` +
`(hub-alias=${DOCKER_HUB_HOSTS.has(requested.host)}); ` +
`candidates=[${candidates || 'none configured'}]`
);
return null;
} catch (e) {
const errorMsg = e instanceof Error ? e.message : String(e);
@@ -2875,10 +2978,14 @@ async function getRegistryBearerToken(registry: string, repo: string): Promise<s
const service = serviceMatch ? serviceMatch[1] : '';
const scope = `repository:${repo}:pull`;
// Step 3: Request token from realm (with credentials if available)
// Step 3: Request token from realm (with credentials if available).
// Empty scope is allowed — means "no specific resource permission".
// Useful for credential validation: some registries (Docker Hub)
// reject privileged scopes like registry:catalog:* even for valid
// users, so omitting scope is the only reliable login check.
const tokenUrl = new URL(realm);
if (service) tokenUrl.searchParams.set('service', service);
tokenUrl.searchParams.set('scope', scope);
if (scope) tokenUrl.searchParams.set('scope', scope);
const tokenHeaders: Record<string, string> = { 'User-Agent': 'Dockhand/1.0' };
@@ -2992,10 +3099,14 @@ export async function getRegistryAuthHeader(
const realm = realmMatch[1];
const service = serviceMatch ? serviceMatch[1] : '';
// Step 3: Request token from realm (with credentials if available)
// Step 3: Request token from realm (with credentials if available).
// Empty scope is allowed — means "no specific resource permission".
// Useful for credential validation: some registries (Docker Hub)
// reject privileged scopes like registry:catalog:* even for valid
// users, so omitting scope is the only reliable login check.
const tokenUrl = new URL(realm);
if (service) tokenUrl.searchParams.set('service', service);
tokenUrl.searchParams.set('scope', scope);
if (scope) tokenUrl.searchParams.set('scope', scope);
const tokenHeaders: Record<string, string> = { 'User-Agent': 'Dockhand/1.0' };
@@ -3140,6 +3251,17 @@ export async function checkImageUpdateAvailable(
};
}
// Skip update check for images tagged against a localhost registry.
// These never resolve from inside Dockhand's container and only produce
// ECONNREFUSED noise (see #1083).
if (/^localhost(:\d+)?\//.test(imageName)) {
return {
hasUpdate: false,
isLocalImage: true,
currentDigest: currentImageId
};
}
// Get current image info to get RepoDigests
let currentImageInfo: any;
try {
@@ -3470,7 +3592,11 @@ export async function listVolumes(envId?: number | null): Promise<VolumeInfo[]>
scope: volume.Scope,
created: volume.CreatedAt,
labels: volume.Labels || {},
usedBy: volumeUsageMap.get(volume.Name) || []
usedBy: volumeUsageMap.get(volume.Name) || [],
// Surface driver_opts (e.g. NFS/CIFS type+device+o) so the UI can
// distinguish network-backed volumes from plain local ones. Docker's
// /volumes endpoint already includes this — we were dropping it.
options: volume.Options && Object.keys(volume.Options).length > 0 ? volume.Options : undefined
}));
}
@@ -4458,11 +4584,21 @@ export async function pushImage(
// Parse tag to get registry info
const [repo, tag = 'latest'] = imageTag.split(':');
// Create X-Registry-Auth header
const authHeader = Buffer.from(JSON.stringify(authConfig)).toString('base64');
const authHeader = encodeRegistryAuth(authConfig);
logAuthDiagnostics(
'[Push]',
authConfig.serveraddress,
authConfig.serveraddress,
authConfig.username ?? '',
authConfig.password ?? '',
authHeader
);
const pushUrl = `/images/${encodeURIComponent(imageTag)}/push`;
console.log(`[Push] POST ${pushUrl} headers=X-Registry-Auth`);
const response = await dockerFetch(
`/images/${encodeURIComponent(imageTag)}/push`,
pushUrl,
{
method: 'POST',
streaming: true,
@@ -4473,8 +4609,11 @@ export async function pushImage(
envId
);
console.log(`[Push] response status=${response.status} ${response.statusText}`);
if (!response.ok) {
const error = await response.text();
console.error(`[Push] error body: ${error}`);
throw new Error(`Failed to push image: ${error}`);
}
@@ -4498,6 +4637,7 @@ export async function pushImage(
try {
const data = JSON.parse(line);
if (data.error) {
console.error(`[Push] stream error: ${line}`);
throw new Error(data.error);
}
if (onProgress) onProgress(data);