1.0.33
This commit is contained in:
85 files changed
+10118
-1046
No files matched your search
+157
-17
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user