/** * Docker Operations Module * * Uses direct Docker API calls over Unix socket or HTTP/HTTPS. * No external dependencies like dockerode - uses Node.js fetch. */ import { homedir } from 'node:os'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; import * as http from 'node:http'; import * as https from 'node:https'; import { Readable } from 'node:stream'; import * as tls from 'node:tls'; import { createHash } from 'node:crypto'; import { pumpWebStreamToWritable } from './stream-pump'; import { computeRequestTimeoutMs } from './backups/request-timeout'; import type { Environment } from './db'; import { getSetting } from './db'; import { getAdditionalVolumeBinds, dedupeVolumesForRecreate } from './mount-dedupe'; import { resolveNanoCpusConflict } from './hostconfig-recreate'; import { rebaseEnvOntoImage, rebaseLabelsOntoImage, rebaseCommand, describeEnvRebase, describeLabelRebase, type ImageEnvLabels } from './container-env-merge'; import { encodeRegistryAuth } from './registry-auth'; import { isSystemContainer, classifyEmptyDigestImage } from './scheduler/tasks/update-utils'; import { deepDiff } from '../utils/diff.js'; import { getInstanceId } from './backups/identity'; import { isOwnedBackupHelper } from './backups/reap-core'; /** * Custom error for when an environment is not found. * API endpoints should catch this and return 404. */ export class EnvironmentNotFoundError extends Error { public readonly envId: number; constructor(envId: number) { super(`Environment ${envId} not found`); this.name = 'EnvironmentNotFoundError'; this.envId = envId; } } /** * Custom error for Docker connection failures with user-friendly messages. * Wraps raw fetch errors to hide technical details from users. */ export class DockerConnectionError extends Error { public readonly originalError: unknown; constructor(message: string, originalError: unknown) { super(message); this.name = 'DockerConnectionError'; this.originalError = originalError; } /** * Create a DockerConnectionError from any error, sanitizing technical messages */ static fromError(error: unknown, context?: string): DockerConnectionError { const errorStr = String(error); let friendlyMessage: string; if (errorStr.includes('FailedToOpenSocket') || errorStr.includes('ECONNREFUSED')) { friendlyMessage = 'Docker socket not accessible'; } else if (errorStr.includes('ECONNRESET') || errorStr.includes('connection was closed')) { friendlyMessage = 'Connection lost'; } else if (errorStr.includes('verbose') || errorStr.includes('typo')) { friendlyMessage = 'Connection failed'; } else if (errorStr.includes('timeout') || errorStr.includes('Timeout') || errorStr.includes('ETIMEDOUT')) { friendlyMessage = 'Connection timeout'; } else if (errorStr.includes('ENOTFOUND') || errorStr.includes('getaddrinfo')) { friendlyMessage = 'Host not found'; } else if (errorStr.includes('EHOSTUNREACH')) { friendlyMessage = 'Host unreachable'; } else { friendlyMessage = 'Connection error'; } if (context) { friendlyMessage = `${context}: ${friendlyMessage}`; } return new DockerConnectionError(friendlyMessage, error); } } /** * Container inspect result from Docker API */ export interface ContainerInspectResult { Id: string; Name: string; RestartCount: number; State: { Status: string; Running: boolean; Paused: boolean; Restarting: boolean; OOMKilled: boolean; Dead: boolean; Pid: number; ExitCode: number; Error: string; StartedAt: string; FinishedAt: string; Health?: { Status: string; FailingStreak: number; Log: Array<{ Start: string; End: string; ExitCode: number; Output: string; }>; }; }; Config: { Hostname: string; User: string; Tty: boolean; Env: string[]; Cmd: string[]; Image: string; Labels: Record; WorkingDir: string; Entrypoint: string[] | null; }; NetworkSettings: { Networks: Record; Ports: Record | null>; }; Mounts: Array<{ Type: string; Source: string; Destination: string; Mode: string; RW: boolean; }>; HostConfig: { Binds: string[] | null; NetworkMode: string; PortBindings: Record> | null; RestartPolicy: { Name: string; MaximumRetryCount: number; }; Privileged: boolean; Memory: number; MemorySwap: number; NanoCpus: number; CpuShares: number; }; } // Detect Docker socket path for local connections function detectDockerSocket(): string { // Check environment variable first if (process.env.DOCKER_SOCKET && existsSync(process.env.DOCKER_SOCKET)) { console.log(`Using Docker socket from DOCKER_SOCKET env: ${process.env.DOCKER_SOCKET}`); return process.env.DOCKER_SOCKET; } // Check DOCKER_HOST environment variable if (process.env.DOCKER_HOST) { const dockerHost = process.env.DOCKER_HOST; if (dockerHost.startsWith('unix://')) { const socketPath = dockerHost.replace('unix://', ''); if (existsSync(socketPath)) { console.log(`Using Docker socket from DOCKER_HOST: ${socketPath}`); return socketPath; } } } // List of possible socket locations in order of preference const possibleSockets = [ '/var/run/docker.sock', // Standard Linux/Docker Desktop `${homedir()}/.docker/run/docker.sock`, // Docker Desktop for Mac (new location) `${homedir()}/.orbstack/run/docker.sock`, // OrbStack '/run/docker.sock', // Alternative Linux location ]; for (const socket of possibleSockets) { if (existsSync(socket)) { console.log(`Detected Docker socket at: ${socket}`); return socket; } } // Fallback to default console.warn('No Docker socket found, using default /var/run/docker.sock'); return '/var/run/docker.sock'; } const socketPath = detectDockerSocket(); /** * Demultiplex Docker stream output (strip 8-byte headers) * Docker streams have: 1 byte type, 3 bytes padding, 4 bytes size BE, then payload */ function demuxDockerStream(buffer: Buffer, options?: { separateStreams?: boolean }): string | { stdout: string; stderr: string } { const stdout: string[] = []; const stderr: string[] = []; let offset = 0; while (offset < buffer.length) { if (offset + 8 > buffer.length) break; const streamType = buffer.readUInt8(offset); const frameSize = buffer.readUInt32BE(offset + 4); if (frameSize === 0 || frameSize > buffer.length - offset - 8) { // Invalid frame, return raw content with control chars stripped const raw = buffer.toString('utf-8').replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, ''); return options?.separateStreams ? { stdout: raw, stderr: '' } : raw; } const payload = buffer.slice(offset + 8, offset + 8 + frameSize).toString('utf-8'); if (streamType === 1) { stdout.push(payload); } else if (streamType === 2) { stderr.push(payload); } else { stdout.push(payload); // Default to stdout for unknown types } offset += 8 + frameSize; } if (options?.separateStreams) { return { stdout: stdout.join(''), stderr: stderr.join('') }; } return [...stdout, ...stderr].join(''); } /** * Process Docker stream frames incrementally from a buffer * Returns processed frames and remaining buffer */ function processStreamFrames( buffer: Buffer, onStdout?: (data: string) => void, onStderr?: (data: string) => void ): { stdout: string; remaining: Buffer } { // Collect stdout frame payloads as raw buffers first, then decode to UTF-8 once // at the end. Decoding each frame individually corrupts multi-byte UTF-8 characters // that may be split across frame boundaries (observed with Grype on Synology NAS). const stdoutChunks: Buffer[] = []; let stdoutLen = 0; let offset = 0; while (buffer.length >= offset + 8) { const streamType = buffer.readUInt8(offset); const frameSize = buffer.readUInt32BE(offset + 4); // Validate stream type (0=stdin, 1=stdout, 2=stderr) if (streamType > 2) break; // Sanity check - no single frame should be > 10MB if (frameSize > 10 * 1024 * 1024) break; if (buffer.length < offset + 8 + frameSize) break; const payloadBuf = buffer.slice(offset + 8, offset + 8 + frameSize); if (streamType === 1) { stdoutChunks.push(payloadBuf); stdoutLen += payloadBuf.length; onStdout?.(payloadBuf.toString('utf-8')); } else if (streamType === 2) { onStderr?.(payloadBuf.toString('utf-8')); } offset += 8 + frameSize; } const stdout = Buffer.concat(stdoutChunks, stdoutLen).toString('utf-8'); return { stdout, remaining: buffer.slice(offset) }; } // Cache for environment configurations with timestamps interface CachedEnv { env: Environment; lastUsed: number; } const envCache = new Map(); // Cache TTL: 30 minutes (in milliseconds) const CACHE_TTL = 30 * 60 * 1000; // All known Docker Hub hostname variations for credential matching 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' ]); // Cleanup stale cache entries periodically function cleanupEnvCache() { const now = Date.now(); const entries = Array.from(envCache.entries()); for (const [envId, cached] of entries) { if (now - cached.lastUsed > CACHE_TTL) { envCache.delete(envId); } } } // Guard against multiple intervals during HMR declare global { var __dockerEnvCacheCleanupInterval: ReturnType | undefined; } // Run cleanup every 10 minutes (guarded to prevent HMR leaks) if (!globalThis.__dockerEnvCacheCleanupInterval) { globalThis.__dockerEnvCacheCleanupInterval = setInterval(cleanupEnvCache, 10 * 60 * 1000); } // ============================================================================= // Per-environment HTTPS Agent pool // ============================================================================= // Used as a fallback when the Go TLS proxy is not available. // Node's https.Agent with keepAlive reuses connections properly. // ============================================================================= interface CachedAgent { agent: https.Agent; lastUsed: number; } const agentCache = new Map(); function getHttpsAgent(config: DockerClientConfig): https.Agent { // Hash actual cert content so rotated certs get a new agent const h = createHash('sha256'); h.update(`${config.host}:${config.port}:`); if (config.ca) h.update(`ca:${config.ca}`); if (config.cert) h.update(`cert:${config.cert}`); if (config.key) h.update(`key:${config.key}`); if (config.skipVerify) h.update('skip'); const key = h.digest('hex'); const cached = agentCache.get(key); if (cached) { cached.lastUsed = Date.now(); return cached.agent; } const agentOptions: https.AgentOptions = { keepAlive: true, timeout: 30000, }; if (config.ca) { // Include both the custom CA and Node.js built-in root certificates. // Node.js replaces the entire CA store when `ca` is set, unlike Bun which appends. // Without this, certs signed by intermediate CAs fail with "unable to get local issuer certificate". agentOptions.ca = [config.ca, ...tls.rootCertificates]; } if (config.cert) agentOptions.cert = config.cert; if (config.key) agentOptions.key = config.key; if (config.skipVerify) agentOptions.rejectUnauthorized = false; const agent = new https.Agent(agentOptions); agentCache.set(key, { agent, lastUsed: Date.now() }); return agent; } function cleanupAgentCache() { const now = Date.now(); for (const [key, cached] of agentCache.entries()) { if (now - cached.lastUsed > CACHE_TTL) { cached.agent.destroy(); agentCache.delete(key); } } } declare global { var __dockerAgentCacheCleanupInterval: ReturnType | undefined; } if (!globalThis.__dockerAgentCacheCleanupInterval) { globalThis.__dockerAgentCacheCleanupInterval = setInterval(cleanupAgentCache, 10 * 60 * 1000); } /** * Make an HTTPS request using Node.js https module with persistent Agent. * Supports both buffered and streaming response modes. */ export function httpsAgentRequest( config: DockerClientConfig, path: string, options: RequestInit = {}, streaming: boolean = false, extraHeaders?: Record ): Promise { return new Promise((resolve, reject) => { const method = (options.method || 'GET').toUpperCase(); const agent = getHttpsAgent(config); const reqHeaders: Record = { ...(extraHeaders || {}) }; if (options.headers) { if (options.headers instanceof Headers) { options.headers.forEach((value, key) => { reqHeaders[key] = value; }); } else if (typeof options.headers === 'object') { Object.assign(reqHeaders, options.headers); } } const reqOptions: https.RequestOptions = { hostname: config.host, port: config.port, path, method, agent, headers: reqHeaders, }; // A stream request BODY (a large tar PUT) must not get the small-request idle timeout // even though the RESPONSE isn't streamed - computeRequestTimeoutMs decides on both. const streamingBody = typeof (options.body as ReadableStream | undefined)?.getReader === 'function'; const timeoutMs = computeRequestTimeoutMs({ path, streamingBody, streamingResponse: streaming, composeTimeoutSecs: parseInt(process.env.COMPOSE_TIMEOUT || '900'), }); if (timeoutMs !== null) reqOptions.timeout = timeoutMs; // Honor AbortSignal from caller (e.g., AbortSignal.timeout(5000) for ping) const signal = options.signal as AbortSignal | undefined; if (signal?.aborted) { reject(new Error('Request aborted')); return; } const req = https.request(reqOptions, (res) => { const headers = new Headers(); for (const [key, value] of Object.entries(res.headers)) { if (value) { if (Array.isArray(value)) { value.forEach(v => headers.append(key, v)); } else { headers.set(key, value); } } } const status = res.statusCode || 200; const statusText = res.statusMessage || ''; // Status codes that must not have a body if ([101, 204, 205, 304].includes(status)) { resolve(new Response(null, { status, statusText, headers })); res.resume(); // drain return; } if (streaming) { const readable = new ReadableStream({ start(controller) { res.on('data', (chunk: Buffer) => controller.enqueue(new Uint8Array(chunk))); res.on('end', () => controller.close()); res.on('error', (err) => controller.error(err)); }, cancel() { res.destroy(); } }); resolve(new Response(readable, { status, statusText, headers })); } else { const chunks: Buffer[] = []; res.on('data', (chunk: Buffer) => chunks.push(chunk)); res.on('end', () => { resolve(new Response(Buffer.concat(chunks), { status, statusText, headers })); }); res.on('error', reject); } }); req.on('error', reject); req.on('timeout', () => { req.destroy(new Error('Request timeout')); }); if (signal) { signal.addEventListener('abort', () => { req.destroy(new Error('Request aborted')); }, { once: true }); } const body = options.body; if (body) { if (typeof body === 'string') { req.end(body); } else if (Buffer.isBuffer(body) || body instanceof Uint8Array) { req.end(body); } else if (body instanceof ArrayBuffer) { req.end(Buffer.from(body)); } else if (body instanceof Blob) { body.arrayBuffer().then(ab => req.end(Buffer.from(ab)), reject); } else if (typeof (body as ReadableStream).getReader === 'function') { // Stream the body with backpressure (O(1) RAM). Without awaiting 'drain' this // piled the whole tar into RAM on a slow mTLS peer - see stream-pump.ts. const reader = (body as ReadableStream).getReader(); pumpWebStreamToWritable(reader, req).catch(() => { /* req.destroy already called */ }); } else { req.end(); } } else { req.end(); } }); } // Import db functions for environment lookup import { getEnvironment } from './db'; // Import hawser edge connection manager for edge mode routing import { sendEdgeRequest, sendEdgeStreamRequest, isEdgeConnected, type EdgeResponse } from './hawser'; /** * Docker API client configuration */ export interface DockerClientConfig { type: 'socket' | 'http' | 'https'; socketPath?: string; host?: string; port?: number; ca?: string; cert?: string; key?: string; skipVerify?: boolean; // Hawser connection settings connectionType?: 'socket' | 'direct' | 'hawser-standard' | 'hawser-edge'; hawserToken?: string; // Environment ID for edge mode routing environmentId?: number; } /** * Build Docker client config from an environment */ function buildConfigFromEnv(env: Environment): DockerClientConfig { // Socket connection type - use Unix socket if (env.connectionType === 'socket' || !env.connectionType) { return { type: 'socket', socketPath: env.socketPath || '/var/run/docker.sock', connectionType: 'socket', environmentId: env.id }; } // Direct or Hawser connection types - use HTTP/HTTPS const protocol = (env.protocol as 'http' | 'https') || 'http'; return { type: protocol, host: env.host || 'localhost', port: env.port || 2375, ca: env.tlsCa || undefined, cert: env.tlsCert || undefined, key: env.tlsKey || undefined, skipVerify: env.tlsSkipVerify || undefined, connectionType: env.connectionType as 'direct' | 'hawser-standard' | 'hawser-edge', hawserToken: env.hawserToken || undefined, environmentId: env.id }; } /** * Get Docker client configuration for an environment */ export async function getDockerConfig(envId?: number | null): Promise { if (!envId) { throw new Error('No environment specified'); } // Check cache first const cached = envCache.get(envId); if (cached) { cached.lastUsed = Date.now(); return buildConfigFromEnv(cached.env); } // Fetch and cache const env = await getEnvironment(envId); if (env) { envCache.set(envId, { env, lastUsed: Date.now() }); return buildConfigFromEnv(env); } throw new EnvironmentNotFoundError(envId); } interface DockerFetchOptions extends RequestInit { /** Set to true for long-lived streaming connections */ streaming?: boolean; } /** * Check if a string is valid base64 */ function isBase64(str: string): boolean { if (!str || str.length === 0) return false; // Base64 strings have length divisible by 4 and contain only valid chars if (str.length % 4 !== 0) return false; return /^[A-Za-z0-9+/]*={0,2}$/.test(str); } /** * Convert EdgeResponse from hawser WebSocket to a standard Response object * Handles base64-encoded binary data from Go agent */ function edgeResponseToResponse(edgeResponse: EdgeResponse): Response { let body: string | Uint8Array = edgeResponse.body; // The Go agent sends isBinary flag to indicate if body is base64-encoded if (edgeResponse.isBinary && typeof body === 'string' && body.length > 0) { // Decode base64 to binary body = Uint8Array.from(atob(body), c => c.charCodeAt(0)); } return new Response(body as BodyInit, { status: edgeResponse.statusCode, headers: edgeResponse.headers }); } /** * Drain a response body to release the underlying socket/TLS connection. * Must be called on any Response whose body won't otherwise be consumed. */ export async function drainResponse(response: Response): Promise { if (!response.bodyUsed) { try { await response.arrayBuffer(); } catch {} } } /** * Drain a Docker API response, throwing if the status is not OK. * Extracts the error message from the JSON body if available. * Accepts optional extra status codes to treat as success (e.g. 304 Not Modified). */ async function throwDockerError(response: Response): Promise { const body = await response.text().catch(() => ''); let msg = `Docker API error: HTTP ${response.status}`; if (body) { try { msg = JSON.parse(body).message ?? body; } catch { msg = body; } } throw new Error(msg); } async function assertDockerResponse(response: Response, ...acceptStatuses: number[]): Promise { if (response.ok || acceptStatuses.includes(response.status)) { await drainResponse(response); return; } await throwDockerError(response); } /** * Make a request to the Docker API * Exported for use by stacks.ts module */ const NULL_BODY_STATUSES = new Set([101, 204, 205, 304]); /** * Build a Web API Response, handling null-body status codes. */ function buildResponse(body: Buffer | ReadableStream, status: number, statusText: string, headers: Headers): Response { if (NULL_BODY_STATUSES.has(status)) { return new Response(null, { status, statusText, headers }); } return new Response(body, { status, statusText, headers }); } /** * Make an HTTP request over a Unix socket and return a Web API Response. * Make an HTTP request over a Unix socket, returning a buffered Web API Response. */ export function unixSocketRequest( socketPath: string, path: string, options: RequestInit = {} ): Promise { return new Promise((resolve, reject) => { const method = (options.method || 'GET').toUpperCase(); const reqOptions: http.RequestOptions = { socketPath, path, method, headers: {}, }; if (options.headers) { if (options.headers instanceof Headers) { options.headers.forEach((value, key) => { (reqOptions.headers as Record)[key] = value; }); } else if (typeof options.headers === 'object') { Object.assign(reqOptions.headers!, options.headers); } } const req = http.request(reqOptions, (res) => { const chunks: Buffer[] = []; res.on('data', (chunk: Buffer) => chunks.push(chunk)); res.on('end', () => { const body = Buffer.concat(chunks); const headers = new Headers(); for (const [key, value] of Object.entries(res.headers)) { if (value) { if (Array.isArray(value)) { value.forEach(v => headers.append(key, v)); } else { headers.set(key, value); } } } resolve(buildResponse(body, res.statusCode || 200, res.statusMessage || '', headers)); }); res.on('error', reject); }); req.on('error', reject); if (options.body instanceof ReadableStream) { // Stream a web ReadableStream (e.g. a large tar for /images/load) straight // to the socket without buffering it in memory. Readable.fromWeb bridges // web -> Node stream; pipe() ends the request when the source finishes. Readable.fromWeb(options.body as any).on('error', reject).pipe(req); } else { if (options.body) { if (typeof options.body === 'string') { req.write(options.body); } else if (options.body instanceof Uint8Array || Buffer.isBuffer(options.body)) { req.write(options.body); } } req.end(); } }); } /** * Make an HTTP request over a Unix socket and return a streaming Web API Response. * Used for long-lived connections like Docker events, logs, stats streaming. */ export function unixSocketStreamRequest( socketPath: string, path: string, options: RequestInit = {} ): Promise { return new Promise((resolve, reject) => { const method = (options.method || 'GET').toUpperCase(); const reqOptions: http.RequestOptions = { socketPath, path, method, headers: {}, }; if (options.headers) { if (options.headers instanceof Headers) { options.headers.forEach((value, key) => { (reqOptions.headers as Record)[key] = value; }); } else if (typeof options.headers === 'object') { Object.assign(reqOptions.headers!, options.headers); } } const req = http.request(reqOptions, (res) => { const headers = new Headers(); for (const [key, value] of Object.entries(res.headers)) { if (value) { if (Array.isArray(value)) { value.forEach(v => headers.append(key, v)); } else { headers.set(key, value); } } } const readable = new ReadableStream({ start(controller) { res.on('data', (chunk: Buffer) => { controller.enqueue(new Uint8Array(chunk)); }); res.on('end', () => { controller.close(); }); res.on('error', (err) => { controller.error(err); }); }, cancel() { res.destroy(); } }); resolve(new Response(readable, { status: res.statusCode || 200, statusText: res.statusMessage || '', headers, })); }); req.on('error', reject); if (options.body instanceof ReadableStream) { // Stream a web ReadableStream (e.g. a large tar for /images/load) straight // to the socket without buffering it in memory. Readable.fromWeb bridges // web -> Node stream; pipe() ends the request when the source finishes. Readable.fromWeb(options.body as any).on('error', reject).pipe(req); } else { if (options.body) { if (typeof options.body === 'string') { req.write(options.body); } else if (options.body instanceof Uint8Array || Buffer.isBuffer(options.body)) { req.write(options.body); } } req.end(); } }); } export async function dockerFetch( path: string, options: DockerFetchOptions = {}, envId?: number | null ): Promise { // Guard against path traversal — legitimate Docker API paths never contain '..' if (path.includes('..')) { throw new Error('Invalid Docker API path'); } const startTime = Date.now(); const config = await getDockerConfig(envId); const { streaming, ...fetchOptions } = options; const method = (options.method || 'GET').toUpperCase(); // Hawser Edge mode - route through WebSocket connection if (config.connectionType === 'hawser-edge' && config.environmentId) { // Check if agent is connected if (!isEdgeConnected(config.environmentId)) { const error = new Error('Hawser Edge agent is not connected'); // Log without stack trace for cleaner output console.warn(`[Docker] Edge env ${config.environmentId}: agent not connected for ${method} ${path}`); throw error; } // Extract request details const headers: Record = {}; // Convert Headers object to plain object if (fetchOptions.headers) { if (fetchOptions.headers instanceof Headers) { fetchOptions.headers.forEach((value, key) => { headers[key] = value; }); } else if (typeof fetchOptions.headers === 'object') { Object.assign(headers, fetchOptions.headers); } } // Parse body if present let body: unknown; let isBinary = false; if (fetchOptions.body) { if (typeof fetchOptions.body === 'string') { try { body = JSON.parse(fetchOptions.body); } catch { body = fetchOptions.body; } } else if (fetchOptions.body instanceof ArrayBuffer || fetchOptions.body instanceof Uint8Array) { // Binary body (tar uploads etc.) — base64 encode for JSON transport const bytes = fetchOptions.body instanceof ArrayBuffer ? new Uint8Array(fetchOptions.body) : fetchOptions.body; body = Buffer.from(bytes).toString('base64'); isBinary = true; } else { body = fetchOptions.body; } } // Send request through edge connection try { const edgeResponse = await sendEdgeRequest( config.environmentId, method, path, body, headers, streaming || false, (streaming || path === '/_hawser/compose' || path.endsWith('/prune')) ? 300000 : 30000, // 5 min for streaming/compose/prune, 30s for normal isBinary, fetchOptions.signal ?? undefined ); const elapsed = Date.now() - startTime; // Only warn for slow requests, but skip /stats which is expected to be slow (5-10s) if (elapsed > 5000 && !path.includes('/stats')) { console.warn(`[Docker] Edge env ${config.environmentId}: ${method} ${path} took ${elapsed}ms`); } return edgeResponseToResponse(edgeResponse); } catch (error: any) { const elapsed = Date.now() - startTime; // Log error message only, not full stack trace const msg = error?.message || String(error); console.error(`[Docker] Edge env ${config.environmentId}: ${method} ${path} failed after ${elapsed}ms: ${msg}`); throw DockerConnectionError.fromError(error); } } if (config.type === 'socket') { // Unix socket via http.request({ socketPath }) try { const requestFn = streaming ? unixSocketStreamRequest : unixSocketRequest; const response = await requestFn(config.socketPath!, path, fetchOptions); const elapsed = Date.now() - startTime; // Only warn for slow requests, but skip /stats which is expected to be slow (5-10s) if (elapsed > 5000 && !path.includes('/stats')) { console.warn(`[Docker] Socket: ${method} ${path} took ${elapsed}ms`); } return response; } catch (error: any) { const elapsed = Date.now() - startTime; // Log error message only, not full stack trace const msg = error?.message || String(error); console.error(`[Docker] Socket: ${method} ${path} failed after ${elapsed}ms: ${msg}`); throw DockerConnectionError.fromError(error); } } else { // HTTP/HTTPS remote connection const protocol = config.type; const url = `${protocol}://${config.host}:${config.port}${path}`; const finalOptions: RequestInit = { ...fetchOptions }; // For Hawser Standard mode with token authentication const extraHeaders: Record = {}; if (config.connectionType === 'hawser-standard' && config.hawserToken) { extraHeaders['X-Hawser-Token'] = config.hawserToken; finalOptions.headers = { ...finalOptions.headers, 'X-Hawser-Token': config.hawserToken }; } // For HTTPS: use node:https with persistent Agent (fallback when Go proxy is down). // For plain HTTP: use standard fetch(). if (config.type === 'https') { try { const response = await httpsAgentRequest(config, path, finalOptions, streaming || false, extraHeaders); const elapsed = Date.now() - startTime; if (elapsed > 5000 && !path.includes('/stats')) { console.warn(`[Docker] ${config.connectionType || 'direct'} ${config.host}: ${method} ${path} took ${elapsed}ms`); } return response; } catch (error: any) { const elapsed = Date.now() - startTime; const msg = error?.message || String(error); console.error(`[Docker] ${config.connectionType || 'direct'} ${config.host}: ${method} ${path} failed after ${elapsed}ms: ${msg}`); throw DockerConnectionError.fromError(error); } } // Plain HTTP — use standard fetch() if (!streaming && !finalOptions.signal) { const isComposeOperation = path === '/_hawser/compose'; const composeTimeoutMs = parseInt(process.env.COMPOSE_TIMEOUT || '900') * 1000; const isPrune = path.endsWith('/prune'); finalOptions.signal = AbortSignal.timeout(isComposeOperation ? composeTimeoutMs : isPrune ? 300000 : 30000); } // A ReadableStream request body (e.g. a large tar for /images/load) requires // duplex:'half' under Node's fetch, else it throws before sending. if (typeof (finalOptions.body as ReadableStream | undefined)?.getReader === 'function') { (finalOptions as RequestInit & { duplex?: string }).duplex = 'half'; } try { const response = await fetch(url, finalOptions); const elapsed = Date.now() - startTime; if (elapsed > 5000 && !path.includes('/stats')) { console.warn(`[Docker] ${config.connectionType || 'direct'} ${config.host}: ${method} ${path} took ${elapsed}ms`); } return response; } catch (error: any) { const elapsed = Date.now() - startTime; const msg = error?.message || String(error); console.error(`[Docker] ${config.connectionType || 'direct'} ${config.host}: ${method} ${path} failed after ${elapsed}ms: ${msg}`); throw DockerConnectionError.fromError(error); } } } /** * Make a JSON request to Docker API */ export async function dockerJsonRequest( path: string, options: RequestInit = {}, envId?: number | null ): Promise { const response = await dockerFetch(path, { ...options, headers: { 'Content-Type': 'application/json', ...options.headers } }, envId); if (!response.ok) { const errorText = await response.text(); let errorJson: any = {}; try { errorJson = JSON.parse(errorText); } catch { // Not JSON, use text as message errorJson = { message: errorText }; } const error: any = new Error(errorJson.message || `Docker API error: ${response.status}`); error.statusCode = response.status; error.json = errorJson; throw error; } return response.json(); } // Clear cached client for an environment (e.g., when settings change) export function clearDockerClientCache(envId?: number) { if (envId !== undefined) { envCache.delete(envId); } else { envCache.clear(); } // Destroy HTTPS agents (TLS config may have changed) for (const [key, cached] of agentCache.entries()) { cached.agent.destroy(); agentCache.delete(key); } } export interface ContainerInfo { id: string; name: string; image: string; /** sha256 image ID (container.ImageID) — matches vulnerability scan.imageId. */ imageId: string; state: string; status: string; created: number; ports: Array<{ IP?: string; PrivatePort: number; PublicPort?: number; Type: string; }>; networks: { [networkName: string]: { ipAddress: string } }; health?: string; restartCount: number; exitCode?: number; mounts: Array<{ type: string; name: string; source: string; destination: string; mode: string; rw: boolean }>; labels: { [key: string]: string }; command: string; } export interface ImageInfo { id: string; tags: string[]; repoDigests?: string[]; size: number; created: number; } // Container operations export async function listContainers(all = true, envId?: number | null): Promise { const containers = await dockerJsonRequest( `/containers/json?all=${all}`, {}, envId ); // Fetch restart counts only for restarting containers const restartCounts = new Map(); const restartingContainers = containers.filter(c => c.State === 'restarting'); await Promise.all( restartingContainers.map(async (container) => { try { const inspect = await inspectContainer(container.Id, envId); restartCounts.set(container.Id, inspect.RestartCount || 0); } catch { // Ignore errors } }) ); // Fetch health status via inspect for containers with healthchecks where Status // string doesn't include health info (Podman compat API omits it from the list endpoint). // Skip entirely if any container already has health in Status (Docker includes it natively). const healthStatuses = new Map(); const healthRegex = /\((healthy|unhealthy|health:\s*starting)\)/i; const anyHasHealthInStatus = containers.some(c => healthRegex.test(c.Status || '')); if (!anyHasHealthInStatus) { // Single API call to find which containers have healthchecks configured try { const healthFiltered = await dockerJsonRequest( '/containers/json?filters=' + encodeURIComponent(JSON.stringify({ health: ['healthy', 'unhealthy', 'starting'] })), {}, envId ); const healthIds = new Set((healthFiltered || []).map((c: any) => c.Id)); // Only inspect those specific containers (typically very few) if (healthIds.size > 0) { await Promise.all( [...healthIds].map(async (id) => { try { const inspect = await inspectContainer(id, envId); const h = inspect.State?.Health?.Status; if (h) healthStatuses.set(id, h); } catch { // Ignore errors } }) ); } } catch { // Ignore - health filter may not be supported } } return containers.map((container) => { // Extract network info with IP addresses const networks: { [networkName: string]: { ipAddress: string } } = {}; if (container.NetworkSettings?.Networks) { for (const [networkName, networkData] of Object.entries(container.NetworkSettings.Networks)) { networks[networkName] = { ipAddress: (networkData as any).IPAddress || '' }; } } // Extract mount info. Carry the volume Name separately from Source: for a // named volume Docker sets Name (e.g. "stack_data") AND Source (the host // path /var/lib/docker/volumes/stack_data/_data). Keeping Name lets the // backup picker show the clean volume name instead of the host path. const mounts = (container.Mounts || []).map((m: any) => ({ type: m.Type || 'unknown', name: m.Name || '', source: m.Source || m.Name || '', destination: m.Destination || '', mode: m.Mode || '', rw: m.RW ?? true })); // Extract health status from Status string // Docker formats: "(healthy)", "(unhealthy)", "(health: starting)" // Podman compat API omits health from Status string, so fall back to inspect data let health: string | undefined; const healthMatch = container.Status?.match(/\((healthy|unhealthy|health:\s*starting)\)/i); if (healthMatch) { const matched = healthMatch[1].toLowerCase(); // Normalize "health: starting" to just "starting" health = matched.includes('starting') ? 'starting' : matched; } else { const inspectHealth = healthStatuses.get(container.Id); if (inspectHealth) { health = inspectHealth === 'health: starting' ? 'starting' : inspectHealth; } } // Parse exit code from Status string (e.g., "Exited (0) 5 minutes ago") const exitCode = container.State === 'exited' ? parseInt(container.Status?.match(/\((\d+)\)/)?.[1] ?? '-1') : undefined; return { id: container.Id, name: container.Names[0]?.replace(/^\//, '') || 'unnamed', image: container.Image, imageId: container.ImageID || '', state: container.State, status: container.Status, created: container.Created, ports: container.Ports || [], networks, health, restartCount: restartCounts.get(container.Id) || 0, exitCode, mounts, labels: container.Labels || {}, command: container.Command || '', systemContainer: isSystemContainer(container.Image || '') }; }); } export async function getContainerStats(id: string, envId?: number | null) { return dockerJsonRequest(`/containers/${id}/stats?stream=false`, {}, envId); } export async function startContainer(id: string, envId?: number | null) { const response = await dockerFetch(`/containers/${id}/start`, { method: 'POST' }, envId); await assertDockerResponse(response, 304); // 304 = already started } export async function stopContainer(id: string, envId?: number | null) { const response = await dockerFetch(`/containers/${id}/stop`, { method: 'POST' }, envId); await assertDockerResponse(response, 304); // 304 = already stopped } export async function restartContainer(id: string, envId?: number | null) { const response = await dockerFetch(`/containers/${id}/restart`, { method: 'POST' }, envId); await assertDockerResponse(response); } export async function pauseContainer(id: string, envId?: number | null) { const response = await dockerFetch(`/containers/${id}/pause`, { method: 'POST' }, envId); await assertDockerResponse(response); } export async function unpauseContainer(id: string, envId?: number | null) { const response = await dockerFetch(`/containers/${id}/unpause`, { method: 'POST' }, envId); await assertDockerResponse(response); } export async function removeContainer(id: string, force = false, envId?: number | null) { const response = await dockerFetch(`/containers/${id}?force=${force}`, { method: 'DELETE' }, envId); await assertDockerResponse(response, 404); // 404 = already gone } export async function renameContainer(id: string, newName: string, envId?: number | null) { const response = await dockerFetch(`/containers/${id}/rename?name=${encodeURIComponent(newName)}`, { method: 'POST' }, envId); 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>, envId?: number | null ): Promise { const allowed = new Set(IN_PLACE_UPDATE_FIELDS); const body: Record = {}; 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( `/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 { // Check if container has TTY enabled const info = await inspectContainer(id, envId); const hasTty = info.Config?.Tty ?? false; let query = `stdout=true&stderr=true×tamps=true`; if (tail === 'all') { query += `&tail=all`; } else { query += `&tail=${tail}`; } if (since) { query += `&since=${since}`; } if (until) { query += `&until=${until}`; } const response = await dockerFetch( `/containers/${id}/logs?${query}`, {}, envId ); if (!response.ok) await throwDockerError(response); const buffer = Buffer.from(await response.arrayBuffer()); // If TTY is enabled, logs are raw text (no demux needed) if (hasTty) { return buffer.toString('utf-8'); } return demuxDockerStream(buffer) as string; } export async function inspectContainer(id: string, envId?: number | null): Promise { return dockerJsonRequest(`/containers/${id}/json`, {}, envId); } export interface HealthcheckConfig { test?: string[]; interval?: number; timeout?: number; retries?: number; startPeriod?: number; } export interface UlimitConfig { name: string; soft: number; hard: number; } export interface DeviceMapping { hostPath: string; containerPath: string; permissions?: string; } /** GPU/device request for containers (e.g., Nvidia GPU) */ export interface DeviceRequest { driver?: string; count?: number; deviceIDs?: string[]; capabilities?: string[][]; options?: { [key: string]: string }; } export interface CreateContainerOptions { name: string; image: string; ports?: { [key: string]: { HostIp?: string; HostPort: string } } | null; volumes?: { [key: string]: {} }; volumeBinds?: string[] | null; env?: string[]; labels?: { [key: string]: string }; cmd?: string[]; entrypoint?: string[]; workingDir?: string; restartPolicy?: string; restartMaxRetries?: number; networkMode?: string; /** Additional networks attached after creation via POST /networks//connect. * Must NOT include the primary (which lives in networkMode + EndpointsConfig). */ additionalNetworks?: string[]; /** @deprecated use networkMode + additionalNetworks. Kept only so old callers * (auto-update inspect path) keep working — see backward-compat block below. */ networks?: string[]; /** Network aliases for the primary network */ networkAliases?: string[]; /** Static IPv4 address for the primary network */ networkIpv4Address?: string; /** Static IPv6 address for the primary network */ networkIpv6Address?: string; /** Gateway priority for the primary network (Docker Engine 28+) */ networkGwPriority?: number; /** Per-network endpoint configuration (IPv4, IPv6, aliases) */ networkConfigs?: Record; user?: string | null; privileged?: boolean; healthcheck?: HealthcheckConfig | null; memory?: number; memoryReservation?: number; memorySwap?: number; cpuShares?: number; cpuQuota?: number; cpuPeriod?: number; nanoCpus?: number; capAdd?: string[]; capDrop?: string[]; devices?: DeviceMapping[]; dns?: string[]; dnsSearch?: string[]; dnsOptions?: string[]; securityOpt?: string[]; ulimits?: UlimitConfig[]; // Terminal settings tty?: boolean; stdinOpen?: boolean; // Process and memory settings oomKillDisable?: boolean; pidsLimit?: number; shmSize?: number; // Tmpfs mounts tmpfs?: { [key: string]: string }; // Sysctls sysctls?: { [key: string]: string }; // Logging configuration logDriver?: string; logOptions?: { [key: string]: string }; // Namespace settings ipcMode?: string; pidMode?: string; utsMode?: string; // Hostname hostname?: string; // Cgroup parent cgroupParent?: string; // Stop signal stopSignal?: string; // Init process init?: boolean; // Stop timeout stopTimeout?: number; // MAC address macAddress?: string; // Extra hosts (/etc/hosts entries) extraHosts?: string[]; // Device requests (GPU access, etc.) deviceRequests?: DeviceRequest[]; // Container runtime (e.g., 'runc', 'nvidia' for GPU containers) runtime?: string | null; // Read-only root filesystem readonlyRootfs?: boolean; // CPU pinning (e.g., "0-3", "0,1") cpusetCpus?: string; // NUMA memory nodes (e.g., "0-1", "0") cpusetMems?: string; // Additional groups for the container process groupAdd?: string[]; // Memory swappiness (0-100) memorySwappiness?: number; // User namespace mode usernsMode?: string; // Domain name domainname?: string; } export async function createContainer(options: CreateContainerOptions, envId?: number | null) { const containerConfig: any = { Image: options.image, Env: options.env || [], Labels: options.labels || {}, HostConfig: { RestartPolicy: { Name: options.restartPolicy || 'no', ...(options.restartPolicy === 'on-failure' && options.restartMaxRetries !== undefined ? { MaximumRetryCount: options.restartMaxRetries } : {}) } } }; if (options.cmd && options.cmd.length > 0) { containerConfig.Cmd = options.cmd; } if (options.user !== undefined) { containerConfig.User = options.user ?? ''; } if (options.healthcheck === null) { // Explicitly disable healthcheck (user cleared it) containerConfig.Healthcheck = { Test: ["NONE"] }; } else if (options.healthcheck) { containerConfig.Healthcheck = {}; if (options.healthcheck.test && options.healthcheck.test.length > 0) { containerConfig.Healthcheck.Test = options.healthcheck.test; } if (options.healthcheck.interval !== undefined) { containerConfig.Healthcheck.Interval = options.healthcheck.interval; } if (options.healthcheck.timeout !== undefined) { containerConfig.Healthcheck.Timeout = options.healthcheck.timeout; } if (options.healthcheck.retries !== undefined) { containerConfig.Healthcheck.Retries = options.healthcheck.retries; } if (options.healthcheck.startPeriod !== undefined) { containerConfig.Healthcheck.StartPeriod = options.healthcheck.startPeriod; } } if (options.ports === null) { // Explicitly clear ports (user removed all mappings) containerConfig.ExposedPorts = {}; containerConfig.HostConfig.PortBindings = {}; } else if (options.ports) { containerConfig.ExposedPorts = {}; containerConfig.HostConfig.PortBindings = {}; for (const [containerPort, hostConfig] of Object.entries(options.ports)) { containerConfig.ExposedPorts[containerPort] = {}; containerConfig.HostConfig.PortBindings[containerPort] = [hostConfig]; } } if (options.volumeBinds === null) { // Explicitly clear volume binds (user removed all) containerConfig.HostConfig.Binds = []; } else if (options.volumeBinds && options.volumeBinds.length > 0) { containerConfig.HostConfig.Binds = options.volumeBinds; } if (options.volumes) { containerConfig.Volumes = options.volumes; } // Backward-compat: callers (and the auto-update inspect path) may still pass // the legacy "networks" array which conflated primary + extras. Normalize to // the new model: anything that isn't the primary becomes an additional network. if (options.networks && !options.additionalNetworks) { const primary = options.networkMode || ''; options.additionalNetworks = options.networks.filter(n => n !== primary); } // Networking model: // - networkMode is the SINGLE source of truth for the primary network // - EndpointsConfig has exactly ONE entry, keyed by networkMode, carrying // the form's IPv4/IPv6/aliases/gwPriority // - additionalNetworks (extras only — NEVER includes the primary) are // attached after container creation via POST /networks/{name}/connect // - Shared modes (host / none / container:X / service:X) skip both // EndpointsConfig and additionalNetworks: Docker rejects them. const primaryMode = options.networkMode || ''; const isSharedMode = primaryMode === 'host' || primaryMode === 'none' || primaryMode.startsWith('container:') || primaryMode.startsWith('service:'); if (primaryMode) { containerConfig.HostConfig.NetworkMode = primaryMode; } // Build the single EndpointsConfig entry for the primary (only for non-shared modes). if (primaryMode && !isSharedMode) { const endpointConfig: any = {}; if (options.networkAliases?.length) { endpointConfig.Aliases = options.networkAliases; } if (options.networkIpv4Address || options.networkIpv6Address) { endpointConfig.IPAMConfig = {}; if (options.networkIpv4Address) endpointConfig.IPAMConfig.IPv4Address = options.networkIpv4Address; if (options.networkIpv6Address) endpointConfig.IPAMConfig.IPv6Address = options.networkIpv6Address; } if (options.networkGwPriority !== undefined) { endpointConfig.GwPriority = options.networkGwPriority; } containerConfig.NetworkingConfig = { EndpointsConfig: { [primaryMode]: endpointConfig } }; } // Extras attached after creation. Empty array for shared modes. const extraNetworks: { name: string; config: any }[] = []; if (!isSharedMode && options.additionalNetworks?.length) { for (const netName of options.additionalNetworks) { if (netName === primaryMode) continue; // primary lives in EndpointsConfig, not here const netCfg = options.networkConfigs?.[netName]; const endpointConfig: any = {}; if (netCfg) { if (netCfg.aliases?.length) endpointConfig.Aliases = netCfg.aliases; if (netCfg.ipv4Address || netCfg.ipv6Address) { endpointConfig.IPAMConfig = {}; if (netCfg.ipv4Address) endpointConfig.IPAMConfig.IPv4Address = netCfg.ipv4Address; if (netCfg.ipv6Address) endpointConfig.IPAMConfig.IPv6Address = netCfg.ipv6Address; } } extraNetworks.push({ name: netName, config: endpointConfig }); } } if (options.privileged !== undefined) { containerConfig.HostConfig.Privileged = options.privileged; } if (options.memory) { containerConfig.HostConfig.Memory = options.memory; } if (options.memoryReservation) { containerConfig.HostConfig.MemoryReservation = options.memoryReservation; } if (options.cpuShares) { containerConfig.HostConfig.CpuShares = options.cpuShares; } if (options.cpuQuota) { containerConfig.HostConfig.CpuQuota = options.cpuQuota; } if (options.cpuPeriod) { containerConfig.HostConfig.CpuPeriod = options.cpuPeriod; } if (options.nanoCpus) { containerConfig.HostConfig.NanoCpus = options.nanoCpus; } if (options.capAdd && options.capAdd.length > 0) { containerConfig.HostConfig.CapAdd = options.capAdd; } if (options.capDrop && options.capDrop.length > 0) { containerConfig.HostConfig.CapDrop = options.capDrop; } if (options.devices && options.devices.length > 0) { containerConfig.HostConfig.Devices = options.devices.map(d => ({ PathOnHost: d.hostPath, PathInContainer: d.containerPath, CgroupPermissions: d.permissions || 'rwm' })); } if (options.dns && options.dns.length > 0) { containerConfig.HostConfig.Dns = options.dns; } if (options.dnsSearch && options.dnsSearch.length > 0) { containerConfig.HostConfig.DnsSearch = options.dnsSearch; } if (options.dnsOptions && options.dnsOptions.length > 0) { containerConfig.HostConfig.DnsOptions = options.dnsOptions; } if (options.securityOpt && options.securityOpt.length > 0) { containerConfig.HostConfig.SecurityOpt = options.securityOpt; } if (options.ulimits && options.ulimits.length > 0) { containerConfig.HostConfig.Ulimits = options.ulimits.map(u => ({ Name: u.name, Soft: u.soft, Hard: u.hard })); } // Entrypoint if (options.entrypoint && options.entrypoint.length > 0) { containerConfig.Entrypoint = options.entrypoint; } // Working directory if (options.workingDir) { containerConfig.WorkingDir = options.workingDir; } // Hostname if (options.hostname) { containerConfig.Hostname = options.hostname; } // TTY and StdinOpen if (options.tty !== undefined) { containerConfig.Tty = options.tty; } if (options.stdinOpen !== undefined) { containerConfig.OpenStdin = options.stdinOpen; } // Memory swap if (options.memorySwap !== undefined) { containerConfig.HostConfig.MemorySwap = options.memorySwap; } // OOM kill disable if (options.oomKillDisable !== undefined) { containerConfig.HostConfig.OomKillDisable = options.oomKillDisable; } // Pids limit if (options.pidsLimit !== undefined) { containerConfig.HostConfig.PidsLimit = options.pidsLimit; } // Shared memory size if (options.shmSize !== undefined) { containerConfig.HostConfig.ShmSize = options.shmSize; } // Tmpfs mounts if (options.tmpfs && Object.keys(options.tmpfs).length > 0) { containerConfig.HostConfig.Tmpfs = options.tmpfs; } // Sysctls if (options.sysctls && Object.keys(options.sysctls).length > 0) { containerConfig.HostConfig.Sysctls = options.sysctls; } // Logging configuration if (options.logDriver) { containerConfig.HostConfig.LogConfig = { Type: options.logDriver, Config: options.logOptions || {} }; } // IPC mode if (options.ipcMode) { containerConfig.HostConfig.IpcMode = options.ipcMode; } // PID mode if (options.pidMode) { containerConfig.HostConfig.PidMode = options.pidMode; } // UTS mode if (options.utsMode) { containerConfig.HostConfig.UTSMode = options.utsMode; } // Cgroup parent if (options.cgroupParent) { containerConfig.HostConfig.CgroupParent = options.cgroupParent; } // Stop signal if (options.stopSignal) { containerConfig.StopSignal = options.stopSignal; } // Init process if (options.init !== undefined) { containerConfig.HostConfig.Init = options.init; } // Stop timeout if (options.stopTimeout !== undefined) { containerConfig.StopTimeout = options.stopTimeout; } // MAC address — set both top-level (API <1.44) and endpoint config (API 1.44+) if (options.macAddress) { containerConfig.MacAddress = options.macAddress; // For Docker API 1.44+, MacAddress must be in EndpointConfig const primaryNetwork = options.networks?.[0] || options.networkMode || 'bridge'; if (containerConfig.NetworkingConfig?.EndpointsConfig?.[primaryNetwork]) { containerConfig.NetworkingConfig.EndpointsConfig[primaryNetwork].MacAddress = options.macAddress; } else { containerConfig.NetworkingConfig = containerConfig.NetworkingConfig || { EndpointsConfig: {} }; containerConfig.NetworkingConfig.EndpointsConfig = containerConfig.NetworkingConfig.EndpointsConfig || {}; containerConfig.NetworkingConfig.EndpointsConfig[primaryNetwork] = { ...containerConfig.NetworkingConfig.EndpointsConfig[primaryNetwork], MacAddress: options.macAddress }; } } // Extra hosts (/etc/hosts entries) if (options.extraHosts && options.extraHosts.length > 0) { containerConfig.HostConfig.ExtraHosts = options.extraHosts; } // Device requests (GPU access, etc.) if (options.deviceRequests && options.deviceRequests.length > 0) { containerConfig.HostConfig.DeviceRequests = options.deviceRequests.map(dr => ({ Driver: dr.driver || '', Count: dr.count ?? -1, DeviceIDs: dr.deviceIDs || [], Capabilities: dr.capabilities || [], Options: dr.options || {} })); } // Container runtime (e.g., 'nvidia' for GPU containers) if (options.runtime !== undefined) { containerConfig.HostConfig.Runtime = options.runtime ?? ''; } // Read-only root filesystem if (options.readonlyRootfs !== undefined) { containerConfig.HostConfig.ReadonlyRootfs = options.readonlyRootfs; } // CPU pinning if (options.cpusetCpus) { containerConfig.HostConfig.CpusetCpus = options.cpusetCpus; } // NUMA memory nodes if (options.cpusetMems) { containerConfig.HostConfig.CpusetMems = options.cpusetMems; } // Additional groups if (options.groupAdd && options.groupAdd.length > 0) { containerConfig.HostConfig.GroupAdd = options.groupAdd; } // Memory swappiness if (options.memorySwappiness !== undefined) { containerConfig.HostConfig.MemorySwappiness = options.memorySwappiness; } // User namespace mode if (options.usernsMode) { containerConfig.HostConfig.UsernsMode = options.usernsMode; } // Domain name if (options.domainname) { containerConfig.Domainname = options.domainname; } // Shared network modes (host / container:X / service:X) inherit the // namespace's networking, so Docker rejects any field that would override it. // Strip them defensively — the merge in updateContainer carries values from // the old config that may not be valid for the new mode (e.g. switching from // bridge → container:foo with a leftover Hostname). // Mirrors the same logic in recreateContainerFromInspect. { const primary = containerConfig.HostConfig?.NetworkMode || ''; const isHost = primary === 'host'; const isContainer = primary.startsWith('container:'); const isService = primary.startsWith('service:'); if (isHost || isContainer || isService) { delete containerConfig.Hostname; delete containerConfig.Domainname; delete containerConfig.MacAddress; } if (isContainer || isService) { // container:X also shares ports / DNS / hosts with the target delete containerConfig.ExposedPorts; if (containerConfig.HostConfig) { delete containerConfig.HostConfig.PortBindings; delete containerConfig.HostConfig.PublishAllPorts; delete containerConfig.HostConfig.Dns; delete containerConfig.HostConfig.DnsOptions; delete containerConfig.HostConfig.DnsSearch; delete containerConfig.HostConfig.ExtraHosts; delete containerConfig.HostConfig.Links; } // NetworkingConfig is meaningless when sharing a namespace delete containerConfig.NetworkingConfig; } } const result = await dockerJsonRequest<{ Id: string }>( `/containers/create?name=${encodeURIComponent(options.name)}`, { method: 'POST', body: JSON.stringify(containerConfig) }, envId ); // Attach additional networks now that the container exists. Docker only allows // a single network at create time, so anything beyond the primary is connected here. for (const extra of extraNetworks) { try { await connectContainerToNetworkRaw(extra.name, result.Id, extra.config, envId); } catch (err: any) { console.error(`Failed to attach additional network "${extra.name}":`, err?.message || err); // Don't fail the whole create — primary network is already connected. // Caller (updateContainer rollback path) will surface this if needed. } } return { id: result.Id, start: () => startContainer(result.Id, envId) }; } /** * Recreate a container using full Config/HostConfig passthrough from inspect data. * Passes Config and HostConfig directly from inspect to create, only changing * the image. No field mapping or stripping. * * Flow: * 1. Stop container * 2. Rename to name-old (frees the name for the new container) * 3. Disconnect all networks (frees static IPs) * 4. Create new container with original name, one network * 5. Connect additional networks * 6. Start new container * 7. Remove old container * * On failure: rollback (rename old back, reconnect networks, restart old) */ export async function recreateContainerFromInspect( inspectData: any, newImage: string, envId?: number | null, log?: (msg: string) => void, /** * The OLD image's Config (Env/Labels), captured BEFORE the new image was * pulled. Required for the env/label rebase (#1226, #1256): once the tag is * repointed the old image may be untagged/GC'd and no longer inspectable, so * it cannot be re-fetched here. When omitted, the rebase falls back to a * best-effort inspect of the old image id and, failing that, verbatim. */ oldImageConfig?: ImageEnvLabels | null ): Promise<{ Id: string }> { const config = inspectData.Config || {}; const hostConfig = inspectData.HostConfig || {}; const networks: Record = inspectData.NetworkSettings?.Networks || {}; const name = inspectData.Name?.replace(/^\//, '') || ''; const oldContainerId = inspectData.Id; const wasRunning = inspectData.State?.Running; // Detect shared/special network modes where network manipulation must be skipped const networkMode = hostConfig.NetworkMode || ''; const isSharedNetwork = networkMode.startsWith('container:') || networkMode.startsWith('service:') || networkMode === 'host' || networkMode === 'none'; if (isSharedNetwork) { log?.(`Shared network mode detected: ${networkMode} — skipping network manipulation`); } // 1. Stop the container if (wasRunning) { log?.('Stopping container...'); await stopContainer(oldContainerId, envId); } // 2. Rename old container to free the name log?.('Renaming old container...'); await dockerFetch( `/containers/${oldContainerId}/rename?name=${encodeURIComponent(name + '-old')}`, { method: 'POST' }, envId ).then(async r => { if (!r.ok) throw new Error('Failed to rename old container'); await drainResponse(r); }); // 3. Disconnect all networks from old container (frees static IPs) // Skip for shared network modes (container:X, host, none) — Docker manages these // Capture the first network for use during container creation let initialNetworkName: string | null = null; let initialNetworkConfig: any = null; if (!isSharedNetwork) { for (const [netName, netConfig] of Object.entries(networks)) { const networkId = (netConfig as any).NetworkID; if (networkId) { try { await disconnectContainerFromNetwork(networkId, oldContainerId, true, envId); } catch { // Best effort - network may already be disconnected } } // Use first network for creation if (!initialNetworkName) { initialNetworkName = netName; initialNetworkConfig = netConfig; } } } // Rollback helper: restore old container on failure const rollback = async () => { try { log?.('Rolling back: restoring old container...'); // Rename back await dockerFetch( `/containers/${oldContainerId}/rename?name=${encodeURIComponent(name)}`, { method: 'POST' }, envId ).then(r => drainResponse(r)).catch(() => {}); // Reconnect networks using full EndpointSettings from inspect if (!isSharedNetwork) { for (const [, netConfig] of Object.entries(networks)) { const nc = netConfig as any; if (nc.NetworkID) { await connectContainerToNetworkRaw(nc.NetworkID, oldContainerId, nc, envId).catch(() => {}); } } } // Restart if (wasRunning) { await startContainer(oldContainerId, envId).catch(() => {}); } } catch { log?.('Rollback failed'); } }; // 4. Build create config - pass Config and HostConfig directly from inspect const createConfig: any = { ...config, Image: newImage, HostConfig: hostConfig }; // 4a. Rebase image-baked env/labels onto the NEW image (#1226, #1256). // A container's Config.Env/Labels are flattened at create time, so the old // image's baked defaults are indistinguishable from user overrides and would // be frozen on recreation. We compare the container's values against the OLD // image and rebase by VALUE: keys the user never touched (value == old image) // follow the new image; user overrides (value differs) and user-only entries // survive. // // The OLD image config MUST come from the caller (captured before the pull) — // after the tag is repointed the old digest is often untagged/GC'd and can no // longer be inspected. We fall back to inspecting the old image id only when // the caller didn't provide it, and to verbatim if that also fails. try { let oldImg = oldImageConfig ?? null; if (!oldImg) { const oldInspect = await inspectImage(inspectData.Image || config.Image, envId); oldImg = { Env: (oldInspect as any)?.Config?.Env, Labels: (oldInspect as any)?.Config?.Labels, Cmd: (oldInspect as any)?.Config?.Cmd ?? null, Entrypoint: (oldInspect as any)?.Config?.Entrypoint ?? null, }; } const newImg = await inspectImage(newImage, envId); const oldEnv = oldImg?.Env || []; const newEnv = (newImg as any)?.Config?.Env || []; const oldLabels = oldImg?.Labels || {}; const newLabels = (newImg as any)?.Config?.Labels || {}; createConfig.Env = rebaseEnvOntoImage(config.Env || [], oldEnv, newEnv); createConfig.Labels = rebaseLabelsOntoImage(config.Labels || {}, oldLabels, newLabels); // 4b. Rebase Cmd/Entrypoint the same way (#1371): a container's Config.Cmd is flattened at // create time, so the OLD image's baked CMD is indistinguishable from a user `command:` // override and would be frozen onto the new image - a container can then start with a // command the new image no longer provides (missing script -> exit 127 -> restart loop). // oldImg?.Cmd == null (caller didn't capture it / inspect failed) -> keep verbatim. const cmdR = rebaseCommand(config.Cmd, oldImg?.Cmd, (newImg as any)?.Config?.Cmd); const entR = rebaseCommand(config.Entrypoint, oldImg?.Entrypoint, (newImg as any)?.Config?.Entrypoint); createConfig.Cmd = cmdR.value; createConfig.Entrypoint = entR.value; if (cmdR.adopted) log?.(`Cmd — adopted from new image: ${JSON.stringify(cmdR.value)} (was ${JSON.stringify(config.Cmd)})`); if (entR.adopted) log?.(`Entrypoint — adopted from new image: ${JSON.stringify(entR.value)} (was ${JSON.stringify(config.Entrypoint)})`); const envSummary = describeEnvRebase(config.Env || [], oldEnv, newEnv); const labelSummary = describeLabelRebase(config.Labels || {}, oldLabels, newLabels); const fmt = (label: string, keys: string[]) => keys.length ? `${label}: ${keys.join(', ')}` : ''; const envParts = [ fmt('adopted from new image', envSummary.adopted), fmt('kept your overrides', envSummary.preserved), fmt('kept your vars', envSummary.userOnly) ].filter(Boolean); log?.(`Env rebase — ${envParts.length ? envParts.join('; ') : 'no changes'}`); const labelParts = [ fmt('adopted from new image', labelSummary.adopted), fmt('kept your overrides', labelSummary.preserved), fmt('kept your labels', labelSummary.userOnly) ].filter(Boolean); log?.(`Label rebase — ${labelParts.length ? labelParts.join('; ') : 'no changes'}`); } catch (e: any) { log?.(`Rebase skipped (image inspect failed), keeping env/labels verbatim: ${e?.message || e}`); } // Strip default MemorySwappiness — Podman + cgroupv2 rejects it. // Docker returns -1, Podman returns 0 when unset. const swappiness = createConfig.HostConfig?.MemorySwappiness; if (swappiness == null || swappiness === -1 || swappiness === 0) { delete createConfig.HostConfig.MemorySwappiness; } // NanoCpus and CpuPeriod/CpuQuota are two ways to express the same CPU limit and are // mutually exclusive at create time. Podman's inspect reports BOTH, so passing the whole // HostConfig back trips its "NanoCpus conflicts with CpuPeriod and CpuQuota" (#1381). if (resolveNanoCpusConflict(createConfig.HostConfig)) { log?.('Dropped CpuPeriod/CpuQuota — they conflict with NanoCpus at create time (kept NanoCpus)'); } // container: mode shares the network namespace — Docker rejects // networking-related fields on the dependent container since they're // owned by the network provider container if (networkMode.startsWith('container:')) { delete createConfig.Hostname; delete createConfig.Domainname; delete createConfig.ExposedPorts; delete createConfig.MacAddress; // HostConfig fields that conflict with container network mode if (createConfig.HostConfig) { delete createConfig.HostConfig.PortBindings; delete createConfig.HostConfig.PublishAllPorts; delete createConfig.HostConfig.DNS; delete createConfig.HostConfig.DNSOptions; delete createConfig.HostConfig.DNSSearch; delete createConfig.HostConfig.ExtraHosts; delete createConfig.HostConfig.Links; } // Resolve container ID references to names for resilience. // Docker stores NetworkMode with the full container SHA ID, but if that container // gets recreated (new ID), the reference goes stale. Using the container name // instead makes the reference survive recreation. const containerRef = networkMode.slice('container:'.length); const isHexId = /^[0-9a-f]{12,64}$/.test(containerRef); if (isHexId) { try { const refInspect = await inspectContainer(containerRef, envId); // Container exists — switch from ID to name for resilience const refName = (refInspect as any).Name?.replace(/^\//, ''); if (refName) { createConfig.HostConfig.NetworkMode = `container:${refName}`; log?.(`Resolved network container ID to name: ${refName}`); } } catch { // Container ID is stale — the referenced container was likely recreated // with a new ID. We can't resolve without knowing the original name. log?.(`WARNING: Network reference container:${containerRef.slice(0, 12)}... is stale (container not found). The container may fail to start if the referenced container was recreated.`); } } } // Deduplicate Config.Volumes against every mount point that will exist on the new container // (Tmpfs, existing binds, the binds we re-add below, and inspect.Mounts). Docker rejects // duplicate mount points; the tmpfs/bind/volume mount wins over the bare image-VOLUME // declaration. Compute additionalBinds first so the dedup sees them (#1088 / #1363). const additionalBinds = getAdditionalVolumeBinds(hostConfig || {}, inspectData.Mounts || []); if (hostConfig) { const kept = dedupeVolumesForRecreate(createConfig.Volumes, hostConfig, inspectData.Mounts || [], additionalBinds); if (kept) createConfig.Volumes = kept; else delete createConfig.Volumes; } if (additionalBinds.length > 0) { createConfig.HostConfig = { ...hostConfig, Binds: [...(hostConfig?.Binds || []), ...additionalBinds] }; } // Docker can only connect to one network at creation. Pass the first network // from the old container's settings to avoid getting a random bridge IP. // Skip for shared network modes — EndpointsConfig conflicts with container:/host/none modes. if (!isSharedNetwork && initialNetworkName && initialNetworkConfig) { const endpointConfig = { ...initialNetworkConfig }; createConfig.NetworkingConfig = { EndpointsConfig: { [initialNetworkName]: endpointConfig } }; // Container-level MacAddress conflicts with endpoint-level MacAddress. // Docker requires them to match or the top-level one to be empty. // The MAC is preserved in the endpoint config (correct location per API v1.44+), // so clear the top-level one to avoid the conflict error. delete createConfig.MacAddress; } // 5. Create new container log?.('Creating new container...'); let newContainerId: string; try { const result = await dockerJsonRequest<{ Id: string }>( `/containers/create?name=${encodeURIComponent(name)}`, { method: 'POST', body: JSON.stringify(createConfig) }, envId ); newContainerId = result.Id; } catch (createError: any) { log?.(`Create failed: ${createError.message}`); await rollback(); throw createError; } // 6. Connect additional networks using full EndpointSettings from inspect // Skip for shared network modes — Docker manages networking via the parent container if (!isSharedNetwork) { for (const [netName, netConfig] of Object.entries(networks)) { if (netName === initialNetworkName) continue; // Already connected at creation const nc = netConfig as any; if (nc.NetworkID) { try { await connectContainerToNetworkRaw(nc.NetworkID, newContainerId, nc, envId); } catch (netError: any) { log?.(`Warning: Failed to connect to network "${netName}": ${netError.message}`); } } } } // 7. Start new container if (wasRunning) { log?.('Starting new container...'); try { await startContainer(newContainerId, envId); } catch (startError: any) { log?.(`Start failed: ${startError.message}, rolling back...`); // Remove failed new container await removeContainer(newContainerId, true, envId).catch(() => {}); await rollback(); throw startError; } } // 8. Log config diff between old and new container try { const newInspect = await inspectContainer(newContainerId, envId); const diffs = deepDiff(inspectData, newInspect); if (diffs.length === 0) { log?.(`[${name}] Config diff: no differences (all settings preserved)`); } else { log?.(`[${name}] Config diff: ${diffs.length} difference(s):`); for (const d of diffs) { log?.(` [${name}] ${d}`); } } } catch { // Non-critical, don't fail the update } // 9. Remove old container (best effort) log?.('Removing old container...'); await removeContainer(oldContainerId, true, envId).catch(() => {}); log?.('Container recreated successfully'); return { Id: newContainerId }; } /** * Ensure `image` is present on the target env, pulling it if not. Docker's * `/containers/create` does NOT auto-pull (unlike `docker run`), so a cross-env * clone restore onto a host that has never seen the image fails with * "No such image". Check-then-pull closes that gap. A pull failure (private * registry, missing arch, real 404) propagates so the caller surfaces the raw * Docker error instead of the more confusing "No such image". */ async function ensureImagePresent(image: string, envId?: number | null, log?: (msg: string) => void): Promise { const res = await dockerFetch(`/images/${encodeURIComponent(image)}/json`, {}, envId); if (res.ok) { await drainResponse(res); return; } await drainResponse(res); log?.(`Image ${image} not present — pulling…`); await pullImage(image, undefined, envId); log?.(`Pulled ${image}`); } /** * Create a container from inspect-like metadata (for restore). * Applies the same edge-case handling as recreateContainerFromInspect * (Podman compat, shared network modes, volume deduplication, etc.) * but without the stop/rename/rollback flow — creates fresh. */ export async function createContainerFromMetadata( containerName: string, image: string, metadata: { config?: any; hostConfig?: any; mounts?: any[]; networkSettings?: { Networks?: Record }; }, envId?: number | null, log?: (msg: string) => void ): Promise<{ Id: string }> { const config = { ...metadata.config } || {}; const hostConfig = { ...metadata.hostConfig } || {}; const networks: Record = metadata.networkSettings?.Networks || {}; const networkMode = hostConfig.NetworkMode || ''; const isSharedNetwork = networkMode.startsWith('container:') || networkMode.startsWith('service:') || networkMode === 'host' || networkMode === 'none'; // Build create config const createConfig: any = { ...config, Image: image, HostConfig: hostConfig }; // Clean up fields not useful for restore delete createConfig.Domainname; delete createConfig.MacAddress; // Strip default MemorySwappiness (Podman + cgroupv2 rejects it) const swappiness = createConfig.HostConfig?.MemorySwappiness; if (swappiness == null || swappiness === -1 || swappiness === 0) { delete createConfig.HostConfig.MemorySwappiness; } // container: mode: clean up conflicting network fields if (networkMode.startsWith('container:')) { delete createConfig.Hostname; delete createConfig.ExposedPorts; if (createConfig.HostConfig) { delete createConfig.HostConfig.PortBindings; delete createConfig.HostConfig.PublishAllPorts; delete createConfig.HostConfig.DNS; delete createConfig.HostConfig.DNSOptions; delete createConfig.HostConfig.DNSSearch; delete createConfig.HostConfig.ExtraHosts; delete createConfig.HostConfig.Links; } } // Preserve anonymous volumes, then dedup Config.Volumes against every resulting mount point // (incl. these added binds + metadata.mounts) so recreate never sends a duplicate (#1088 / #1363). const additionalBinds = getAdditionalVolumeBinds(hostConfig || {}, metadata.mounts || []); if (hostConfig) { const kept = dedupeVolumesForRecreate(createConfig.Volumes, hostConfig, metadata.mounts || [], additionalBinds); if (kept) createConfig.Volumes = kept; else delete createConfig.Volumes; } if (additionalBinds.length > 0) { createConfig.HostConfig = { ...hostConfig, Binds: [...(hostConfig?.Binds || []), ...additionalBinds] }; } // Set initial network for creation if (!isSharedNetwork) { const firstNetName = Object.keys(networks)[0]; const firstNetConfig = firstNetName ? networks[firstNetName] : null; if (firstNetName && firstNetConfig) { // Strip NetworkID — it's the source env's ID and won't match on // the target. Docker resolves by name when NetworkID is absent. const { NetworkID: _id, ...endpointConfig } = firstNetConfig as Record; createConfig.NetworkingConfig = { EndpointsConfig: { [firstNetName]: endpointConfig } }; } } // Pull the image first if the target env doesn't have it — a cross-env clone // restore lands on a host that may never have seen this image, and create // (unlike run) won't pull it. await ensureImagePresent(image, envId, log); // Create container log?.(`Creating container ${containerName}...`); const result = await dockerJsonRequest<{ Id: string }>( `/containers/create?name=${encodeURIComponent(containerName)}`, { method: 'POST', body: JSON.stringify(createConfig) }, envId ); const newContainerId = result.Id; // Connect additional networks (by name — source NetworkIDs don't exist on // the target after a cross-env restore, and even on same-env restores the // network may have been recreated with a new ID). if (!isSharedNetwork) { const firstNetName = Object.keys(networks)[0]; for (const [netName, netConfig] of Object.entries(networks)) { if (netName === firstNetName) continue; const { NetworkID: _id, ...nc } = (netConfig as Record); try { await connectContainerToNetworkRaw(netName, newContainerId, nc, envId); } catch (netError: any) { log?.(`Warning: Failed to connect to network "${netName}": ${netError.message}`); } } } // Start container log?.(`Starting container ${containerName}...`); await startContainer(newContainerId, envId); log?.(`Container ${containerName} created and started (${newContainerId.slice(0, 12)})`); return { Id: newContainerId }; } /** * Extract all container options from Docker inspect data. * This preserves ALL container settings for recreation. * Used by both updateContainer and recreateContainer to ensure consistency. */ export function extractContainerOptions(inspectData: any): CreateContainerOptions { const config = inspectData.Config; const hostConfig = inspectData.HostConfig; const name = inspectData.Name?.replace(/^\//, '') || ''; // Port bindings - preserve all host port mappings including HostIp const ports: { [key: string]: { HostIp?: string; HostPort: string } } = {}; if (hostConfig.PortBindings) { for (const [containerPort, bindings] of Object.entries(hostConfig.PortBindings)) { if (bindings && (bindings as any[]).length > 0) { const binding = (bindings as any[])[0]; ports[containerPort] = { HostPort: binding.HostPort || '' }; // Preserve HostIp if specified (e.g., '192.168.0.250:80:80' in compose) if (binding.HostIp) { ports[containerPort].HostIp = binding.HostIp; } } } } // Volume bindings - preserve ALL volumes including anonymous volumes const volumeBinds: string[] = []; const mountedPaths = new Set(); // First, add all entries from hostConfig.Binds (named volumes and bind mounts) if (hostConfig.Binds && Array.isArray(hostConfig.Binds)) { for (const bind of hostConfig.Binds) { volumeBinds.push(bind); const parts = bind.split(':'); if (parts.length >= 2) { mountedPaths.add(parts[1].split(':')[0]); } } } // Then, add anonymous volumes from Mounts that aren't already in Binds const mounts = inspectData.Mounts || []; for (const mount of mounts) { if (mount.Type === 'volume' && mount.Name && mount.Destination) { if (!mountedPaths.has(mount.Destination)) { const bindStr = mount.RW === false ? `${mount.Name}:${mount.Destination}:ro` : `${mount.Name}:${mount.Destination}`; volumeBinds.push(bindStr); } } } // Healthcheck configuration let healthcheck: HealthcheckConfig | undefined = undefined; if (config.Healthcheck && config.Healthcheck.Test && config.Healthcheck.Test.length > 0) { if (config.Healthcheck.Test[0] !== 'NONE') { healthcheck = { test: config.Healthcheck.Test, interval: config.Healthcheck.Interval, timeout: config.Healthcheck.Timeout, retries: config.Healthcheck.Retries, startPeriod: config.Healthcheck.StartPeriod }; } } // Device mappings const devices = (hostConfig.Devices || []).map((d: any) => ({ hostPath: d.PathOnHost || '', containerPath: d.PathInContainer || '', permissions: d.CgroupPermissions || 'rwm' })).filter((d: any) => d.hostPath && d.containerPath); // Ulimits const ulimits = (hostConfig.Ulimits || []).map((u: any) => ({ name: u.Name, soft: u.Soft, hard: u.Hard })); // Extract network settings const networkSettings = inspectData.NetworkSettings?.Networks || {}; const primaryNetwork = hostConfig.NetworkMode || 'bridge'; // Extract primary network aliases, static IP, and gateway priority let networkAliases: string[] | undefined; let networkIpv4Address: string | undefined; let networkIpv6Address: string | undefined; let macAddress: string | undefined; let networkGwPriority: number | undefined; const containerId = inspectData.Id || ''; const shortContainerId = containerId.substring(0, 12); // Extract compose labels for alias reconstruction const composeProject = config.Labels?.['com.docker.compose.project']; const composeService = config.Labels?.['com.docker.compose.service']; for (const [netName, netConfig] of Object.entries(networkSettings)) { const netConf = netConfig as any; const isPrimary = netName === primaryNetwork || (primaryNetwork === 'bridge' && (netName === 'bridge' || netName === 'default')); if (isPrimary) { // Filter out auto-generated container IDs const allAliases = (netConf.Aliases?.length > 0 ? netConf.Aliases : netConf.DNSNames) || []; networkAliases = allAliases.filter((a: string) => a !== containerId && a !== shortContainerId ); // For compose containers, ensure service name and project-service aliases if (composeProject && composeService) { if (!networkAliases) networkAliases = []; if (!networkAliases.includes(composeService)) { networkAliases.push(composeService); } const projectService = `${composeProject}-${composeService}`; if (!networkAliases.includes(projectService)) { networkAliases.push(projectService); } } if (!networkAliases || networkAliases.length === 0) { networkAliases = undefined; } networkIpv4Address = netConf.IPAMConfig?.IPv4Address || undefined; networkIpv6Address = netConf.IPAMConfig?.IPv6Address || undefined; macAddress = netConf.MacAddress || undefined; networkGwPriority = netConf.GwPriority !== undefined && netConf.GwPriority !== 0 ? netConf.GwPriority : undefined; break; } } // Device requests (GPU, etc.) const deviceRequests = hostConfig.DeviceRequests?.length > 0 ? hostConfig.DeviceRequests.map((dr: any) => ({ driver: dr.Driver || undefined, count: dr.Count, deviceIDs: dr.DeviceIDs?.length > 0 ? dr.DeviceIDs : undefined, capabilities: dr.Capabilities?.length > 0 ? dr.Capabilities : undefined, options: dr.Options && Object.keys(dr.Options).length > 0 ? dr.Options : undefined })) : undefined; return { name, image: config.Image, // Command and entrypoint cmd: config.Cmd || undefined, entrypoint: config.Entrypoint || undefined, workingDir: config.WorkingDir || undefined, // Environment and labels env: config.Env || [], labels: config.Labels || {}, // Port mappings ports: Object.keys(ports).length > 0 ? ports : undefined, // Volume bindings volumeBinds: volumeBinds.length > 0 ? volumeBinds : undefined, // Restart policy restartPolicy: hostConfig.RestartPolicy?.Name || 'no', restartMaxRetries: hostConfig.RestartPolicy?.MaximumRetryCount, // Network settings networkMode: hostConfig.NetworkMode || undefined, networkAliases, networkIpv4Address, networkIpv6Address, networkGwPriority, // User and hostname user: config.User || undefined, hostname: config.Hostname || undefined, domainname: config.Domainname || undefined, // Privileged mode privileged: hostConfig.Privileged || undefined, // Healthcheck healthcheck, // Terminal settings tty: config.Tty || undefined, stdinOpen: config.OpenStdin || undefined, // Memory limits memory: hostConfig.Memory || undefined, memoryReservation: hostConfig.MemoryReservation || undefined, memorySwap: hostConfig.MemorySwap || undefined, // CPU limits cpuShares: hostConfig.CpuShares || undefined, cpuQuota: hostConfig.CpuQuota || undefined, cpuPeriod: hostConfig.CpuPeriod || undefined, nanoCpus: hostConfig.NanoCpus || undefined, // Capabilities capAdd: hostConfig.CapAdd?.length > 0 ? hostConfig.CapAdd : undefined, capDrop: hostConfig.CapDrop?.length > 0 ? hostConfig.CapDrop : undefined, // Devices devices: devices.length > 0 ? devices : undefined, // DNS settings dns: hostConfig.Dns?.length > 0 ? hostConfig.Dns : undefined, dnsSearch: hostConfig.DnsSearch?.length > 0 ? hostConfig.DnsSearch : undefined, dnsOptions: hostConfig.DnsOptions?.length > 0 ? hostConfig.DnsOptions : undefined, // Security options securityOpt: hostConfig.SecurityOpt?.length > 0 ? hostConfig.SecurityOpt : undefined, // Ulimits ulimits: ulimits.length > 0 ? ulimits : undefined, // Process and memory settings oomKillDisable: hostConfig.OomKillDisable || undefined, pidsLimit: hostConfig.PidsLimit || undefined, shmSize: hostConfig.ShmSize || undefined, // Tmpfs mounts tmpfs: hostConfig.Tmpfs && Object.keys(hostConfig.Tmpfs).length > 0 ? hostConfig.Tmpfs : undefined, // Sysctls sysctls: hostConfig.Sysctls && Object.keys(hostConfig.Sysctls).length > 0 ? hostConfig.Sysctls : undefined, // Logging configuration logDriver: hostConfig.LogConfig?.Type || undefined, logOptions: hostConfig.LogConfig?.Config && Object.keys(hostConfig.LogConfig.Config).length > 0 ? hostConfig.LogConfig.Config : undefined, // Namespace settings ipcMode: hostConfig.IpcMode || undefined, pidMode: hostConfig.PidMode || undefined, utsMode: hostConfig.UTSMode || undefined, // Cgroup parent cgroupParent: hostConfig.CgroupParent || undefined, // Stop signal and timeout stopSignal: config.StopSignal || undefined, stopTimeout: config.StopTimeout || undefined, // Init process init: hostConfig.Init === true ? true : undefined, // MAC address macAddress, // Extra hosts extraHosts: hostConfig.ExtraHosts?.length > 0 ? hostConfig.ExtraHosts : undefined, // Device requests (GPU) deviceRequests, // Container runtime runtime: hostConfig.Runtime && hostConfig.Runtime !== 'runc' ? hostConfig.Runtime : undefined, // Read-only root filesystem readonlyRootfs: hostConfig.ReadonlyRootfs === true ? true : undefined, // CPU pinning cpusetCpus: hostConfig.CpusetCpus || undefined, cpusetMems: hostConfig.CpusetMems || undefined, // Additional groups groupAdd: hostConfig.GroupAdd?.length > 0 ? hostConfig.GroupAdd : undefined, // Memory swappiness memorySwappiness: hostConfig.MemorySwappiness != null && hostConfig.MemorySwappiness !== -1 && hostConfig.MemorySwappiness !== 0 ? hostConfig.MemorySwappiness : undefined, // User namespace mode usernsMode: hostConfig.UsernsMode || undefined }; } /** * Update a container by recreating it with merged options. * Preserves ALL existing container settings and merges user-provided options on top. */ export async function updateContainer(id: string, options: Partial, startAfterUpdate = false, envId?: number | null) { const oldContainerInfo = await inspectContainer(id, envId); const wasRunning = oldContainerInfo.State.Running; const name = oldContainerInfo.Name?.replace(/^\//, '') || ''; const oldContainerId = oldContainerInfo.Id; const networks: Record = oldContainerInfo.NetworkSettings?.Networks || {}; const hostConfig = oldContainerInfo.HostConfig || {}; const networkMode = hostConfig.NetworkMode || ''; const isSharedNetwork = networkMode.startsWith('container:') || networkMode.startsWith('service:') || networkMode === 'host' || networkMode === 'none'; // Extract ALL existing container options const existingOptions = extractContainerOptions(oldContainerInfo); // Per-network fields (aliases, static IPs, MAC, gateway priority) are scoped to // a single network. When the user switches the primary network, the values // extracted from the old network must NOT follow the container — e.g. compose // service aliases from "anton" applied to the default bridge fail with // "invalid endpoint settings" because the default bridge doesn't accept aliases. if (options.networkMode && options.networkMode !== networkMode) { existingOptions.networkAliases = undefined; existingOptions.networkIpv4Address = undefined; existingOptions.networkIpv6Address = undefined; existingOptions.networkGwPriority = undefined; existingOptions.macAddress = undefined; } // Merge user-provided options on top of existing options. // Semantics (#1119): // - key absent from `options` → preserve existingOptions[key] // - key present with explicit `null` → CLEAR the field (treat as undefined) // - key present with any other value → use the user's value // // Without the null-as-clear handling, clearing a Resources field in the UI // (which sends null) would merge over the existing value identically to a // preserved field — Memory limit, etc. would silently stick around. const userOptions: Record = {}; for (const [k, v] of Object.entries(options)) { userOptions[k] = v === null ? undefined : v; } const mergedOptions: CreateContainerOptions = { ...existingOptions, ...userOptions, // Replace labels, but preserve Docker internal labels (com.docker.*) labels: options.labels !== undefined && options.labels !== null ? { ...Object.fromEntries( Object.entries(existingOptions.labels || {}).filter(([k]) => k.startsWith('com.docker.')) ), ...options.labels } : options.labels === null ? {} : existingOptions.labels }; // 1. Stop old container if (wasRunning) { await stopContainer(id, envId); } // 2. Rename old container to free the name (instead of removing — allows rollback) await dockerFetch( `/containers/${oldContainerId}/rename?name=${encodeURIComponent(name + '-old')}`, { method: 'POST' }, envId ).then(async r => { if (!r.ok) throw new Error('Failed to rename old container'); await drainResponse(r); }); // 3. Disconnect networks from old container to free static IPs if (!isSharedNetwork) { for (const [, netConfig] of Object.entries(networks)) { const nc = netConfig as any; if (nc.NetworkID) { await disconnectContainerFromNetwork(nc.NetworkID, oldContainerId, true, envId).catch(() => {}); } } } // Rollback helper: restore old container on failure const rollback = async () => { try { // Rename back await dockerFetch( `/containers/${oldContainerId}/rename?name=${encodeURIComponent(name)}`, { method: 'POST' }, envId ).then(r => drainResponse(r)).catch(() => {}); // Reconnect networks if (!isSharedNetwork) { for (const [, netConfig] of Object.entries(networks)) { const nc = netConfig as any; if (nc.NetworkID) { await connectContainerToNetworkRaw(nc.NetworkID, oldContainerId, nc, envId).catch(() => {}); } } } // Restart if it was running if (wasRunning) { await startContainer(oldContainerId, envId).catch(() => {}); } } catch { // Rollback is best-effort } }; // 4. Create new container let newContainer; try { newContainer = await createContainer(mergedOptions, envId); } catch (createError) { await rollback(); throw createError; } // 5. Start if needed if (startAfterUpdate) { try { await newContainer.start(); } catch (startError) { // Remove failed new container and restore old one await removeContainer(newContainer.id, true, envId).catch(() => {}); await rollback(); throw startError; } } // 6. Remove old container (success path only) await removeContainer(oldContainerId, true, envId).catch(() => {}); return newContainer; } // Image operations export async function listImages(envId?: number | null): Promise { // Fetch images and containers in parallel const [images, containers] = await Promise.all([ dockerJsonRequest('/images/json', {}, envId), dockerJsonRequest('/containers/json?all=true', {}, envId).catch(() => [] as any[]) ]); // Build a map of imageId -> container count // Docker may return -1 for Containers field on some hosts, so we compute it ourselves const imageContainerCount = new Map(); for (const container of containers) { const imageId = container.ImageID || container.Image; if (imageId) { imageContainerCount.set(imageId, (imageContainerCount.get(imageId) || 0) + 1); } } return images.map((image) => ({ id: image.Id, repoTags: image.RepoTags || [], tags: image.RepoTags || [], repoDigests: image.RepoDigests || [], size: image.Size, virtualSize: image.VirtualSize || image.Size, created: image.Created, labels: image.Labels || {}, containers: imageContainerCount.get(image.Id) || 0 })); } /** * Diagnostic log for registry auth headers (#1105). * Logs only whether credentials were present and the target registry — no * credential-derived material (lengths, byte values, or header bytes). */ function logAuthDiagnostics( tag: string, registry: string, serveraddress: string, username: string, password: string, authHeader: string ): void { console.log( `${tag} auth: registry=${registry} serveraddress=${serveraddress} ` + `hasUser=${username.length > 0} hasPassword=${password.length > 0} hasAuthHeader=${authHeader.length > 0}` ); } /** * Build X-Registry-Auth header for authenticated Docker image pulls. * Looks up stored registry credentials and returns a headers object * with the base64-encoded auth config, or an empty object if no credentials found. */ export async function buildRegistryAuthHeader(imageName: string): Promise> { const headers: Record = {}; try { const { registry } = parseImageReference(imageName); const creds = await findRegistryCredentials(registry); if (creds) { // Docker Engine requires 'https://index.docker.io/v1/' as serveraddress // for Docker Hub auth — just the hostname is treated as unauthenticated 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 }; 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}`); } } catch (e) { const errorMsg = e instanceof Error ? e.message : String(e); console.error(`[Pull] Failed to lookup credentials:`, errorMsg); } return headers; } export async function pullImage(imageName: string, onProgress?: (data: any) => void, envId?: number | null) { // Parse image name and tag to avoid pulling all tags // Docker API: if tag is empty, it pulls ALL tags for the image // Format can be: repo:tag, repo@digest, or just repo (defaults to :latest) let fromImage = imageName; let tag = 'latest'; if (imageName.includes('@')) { // Image with digest: repo@sha256:abc123 // Don't split, pass as-is (digest is part of fromImage) fromImage = imageName; tag = ''; // Empty tag when using digest } else if (imageName.includes(':')) { // Image with tag: repo:tag or registry.example.com/repo:tag const lastColonIndex = imageName.lastIndexOf(':'); const potentialTag = imageName.substring(lastColonIndex + 1); // Make sure we're not splitting on a port number (e.g., registry.example.com:5000/repo) // Tags don't contain slashes, but registry ports are followed by a path if (!potentialTag.includes('/')) { fromImage = imageName.substring(0, lastColonIndex); tag = potentialTag; } } // Build URL with explicit tag parameter to prevent pulling all tags const url = tag ? `/images/create?fromImage=${encodeURIComponent(fromImage)}&tag=${encodeURIComponent(tag)}` : `/images/create?fromImage=${encodeURIComponent(fromImage)}`; // 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) { 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 const reader = response.body?.getReader(); if (!reader) return; const decoder = new TextDecoder(); let buffer = ''; // Docker streams pull failures as {"error":...} lines inside a 200 response. // Capture the last one and throw after the stream ends so callers see the // failure (and onProgress still fires for the error line). let streamError: string | null = null; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) { if (line.trim()) { try { const data = JSON.parse(line); if (data.error || data.errorDetail) { console.error(`[Pull] stream error: ${line}`); streamError = data.errorDetail?.message || data.error || 'Image pull failed'; } if (onProgress) onProgress(data); } catch { // Ignore parse errors } } } } if (streamError) { throw new Error(`Failed to pull image: ${streamError}`); } } export async function removeImage(id: string, force = false, envId?: number | null) { const response = await dockerFetch(`/images/${encodeURIComponent(id)}?force=${force}`, { method: 'DELETE' }, envId); if (!response.ok) { const data = await response.json().catch(() => ({})); const error: any = new Error(data.message || 'Failed to remove image'); error.statusCode = response.status; error.json = data; throw error; } await drainResponse(response); } export async function getImageHistory(id: string, envId?: number | null) { return dockerJsonRequest(`/images/${encodeURIComponent(id)}/history`, {}, envId); } export async function inspectImage(id: string, envId?: number | null) { return dockerJsonRequest(`/images/${encodeURIComponent(id)}/json`, {}, envId); } /** * Parse an image reference into registry, repository, and tag components. * Follows Docker's reference parsing rules. * Examples: * nginx:latest -> { registry: 'index.docker.io', repo: 'library/nginx', tag: 'latest' } * ghcr.io/user/image:v1 -> { registry: 'ghcr.io', repo: 'user/image', tag: 'v1' } * registry.example.com:5000/repo:tag -> { registry: 'registry.example.com:5000', repo: 'repo', tag: 'tag' } */ function parseImageReference(imageName: string): { registry: string; repo: string; tag: string } { let registry = 'index.docker.io'; // Docker Hub's actual host let repo = imageName; let tag = 'latest'; // Handle digest references (remove digest part for manifest lookup) if (repo.includes('@')) { const [repoWithoutDigest] = repo.split('@'); repo = repoWithoutDigest; } // Extract tag const lastColon = repo.lastIndexOf(':'); if (lastColon > -1) { const potentialTag = repo.substring(lastColon + 1); // Make sure it's not a port number (no slashes in tags) if (!potentialTag.includes('/')) { tag = potentialTag; repo = repo.substring(0, lastColon); } } // Extract registry if present const firstSlash = repo.indexOf('/'); if (firstSlash > -1) { const firstPart = repo.substring(0, firstSlash); // If the first part contains a dot, colon, or is "localhost", it's a registry if (firstPart.includes('.') || firstPart.includes(':') || firstPart === 'localhost') { registry = firstPart; repo = repo.substring(firstSlash + 1); } } // Normalize docker.io to index.docker.io (Docker Hub's actual registry host) // docker.io redirects to www.docker.com, while index.docker.io is the real API if (registry === 'docker.io') { registry = 'index.docker.io'; } // Docker Hub requires library/ prefix for official images if (registry === 'index.docker.io' && !repo.includes('/')) { repo = `library/${repo}`; } return { registry, repo, tag }; } /** * Parse a registry URL into host and path components. * Handles URLs with or without protocol, and preserves organization paths. * * Examples: * 'https://registry.example.com/org' -> { host: 'registry.example.com', path: '/org', fullRegistry: 'registry.example.com/org' } * 'ghcr.io' -> { host: 'ghcr.io', path: '', fullRegistry: 'ghcr.io' } * 'registry.example.com:5000/myorg' -> { host: 'registry.example.com:5000', path: '/myorg', fullRegistry: 'registry.example.com:5000/myorg' } */ export function parseRegistryUrl(url: string): { host: string; path: string; fullRegistry: string; protocol: string } { // Detect protocol (default to https) const protocol = url.startsWith('http://') ? 'http' : 'https'; // Remove protocol const withoutProtocol = url.replace(/^https?:\/\//, ''); // Remove trailing slash const trimmed = withoutProtocol.replace(/\/$/, ''); // Split on first slash (after port if present) const slashIndex = trimmed.indexOf('/'); if (slashIndex === -1) { return { host: trimmed, path: '', fullRegistry: trimmed, protocol }; } const host = trimmed.substring(0, slashIndex); const path = trimmed.substring(slashIndex); // includes leading / return { host, path, fullRegistry: trimmed, protocol }; } /** * Find registry credentials from Dockhand's stored registries. * Matches by registry URL including organization path if present. * * Matching logic: * - Full match: stored 'registry.example.com/org' matches requested 'registry.example.com/org' * - Host-only stored: stored 'registry.example.com' matches requested 'registry.example.com/org' * (allows a single credential entry to work for all org paths) */ async function findRegistryCredentials(registryHost: string): Promise<{ username: string; password: string } | null> { try { // Import here to avoid circular dependency const { getRegistries } = await import('./db.js'); const registries = await getRegistries(); const requested = parseRegistryUrl(registryHost); for (const reg of registries) { const stored = parseRegistryUrl(reg.url); // Match if: // 1. Full registry paths match exactly, OR // 2. Hosts match and stored registry has no path (applies to any org) if (stored.fullRegistry === requested.fullRegistry || (stored.host === requested.host && !stored.path)) { if (reg.username && 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() }; } } } // Bidirectional Docker Hub alias matching: // If the requested host is any Docker Hub variant, match against any stored Docker Hub variant if (DOCKER_HUB_HOSTS.has(requested.host)) { for (const reg of registries) { const stored = parseRegistryUrl(reg.url); if (DOCKER_HUB_HOSTS.has(stored.host)) { if (reg.username && reg.password) { console.log(`[Registry] matched stored=${reg.url} requested=${registryHost} via=hub-alias`); return { username: reg.username.trim(), password: reg.password.trim() }; } } } } // No stored credentials matched. This is NOT an error for public images — // anonymous access still works; it just means no auth header is attached. // (A Docker Hub entry configured without a username/password is anonymous, // so it logs here even though hub-alias matched the host — see #1255.) const candidates = registries.map(r => parseRegistryUrl(r.url).host).join(', '); console.log( `[Registry] no stored credentials for requested=${registryHost} ` + `(hub-alias=${DOCKER_HUB_HOSTS.has(requested.host)}); using anonymous access; ` + `candidates=[${candidates || 'none configured'}]` ); return null; } catch (e) { const errorMsg = e instanceof Error ? e.message : String(e); console.error('[Registry] Failed to lookup credentials:', errorMsg); return null; } } /** * Get bearer token from registry using challenge-response flow. * This follows the Docker Registry v2 authentication spec: * 1. Make request to /v2/ to get WWW-Authenticate challenge * 2. Parse realm, service, scope from challenge * 3. Request token from realm URL (with credentials if available) */ async function getRegistryBearerToken(registry: string, repo: string): Promise { try { const registryUrl = `https://${registry}`; // Look up stored credentials for this registry const credentials = await findRegistryCredentials(registry); // Step 1: Challenge request to /v2/ const challengeResponse = await fetch(`${registryUrl}/v2/`, { method: 'GET', headers: { 'User-Agent': 'Dockhand/1.0' } }); // If 200, no auth needed if (challengeResponse.ok) { await drainResponse(challengeResponse); return null; } // If not 401, something else is wrong if (challengeResponse.status !== 401) { await drainResponse(challengeResponse); console.error(`Registry challenge failed: ${challengeResponse.status}`); return null; } // Step 2: Parse WWW-Authenticate header const wwwAuth = challengeResponse.headers.get('WWW-Authenticate') || ''; const challenge = wwwAuth.toLowerCase(); if (challenge.startsWith('basic')) { // Basic auth - use credentials if we have them await drainResponse(challengeResponse); if (credentials) { const basicAuth = Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64'); return `Basic ${basicAuth}`; } return null; } if (!challenge.startsWith('bearer')) { await drainResponse(challengeResponse); console.error(`Unsupported auth type: ${wwwAuth}`); return null; } // Drain 401 response body before bearer token fetch (required by Node.js/Undici for connection reuse) await drainResponse(challengeResponse); // Parse bearer challenge: Bearer realm="...",service="...",scope="..." const realmMatch = wwwAuth.match(/realm="([^"]+)"/i); const serviceMatch = wwwAuth.match(/service="([^"]+)"/i); if (!realmMatch) { console.error('No realm in WWW-Authenticate header'); return null; } const realm = realmMatch[1]; const service = serviceMatch ? serviceMatch[1] : ''; const scope = `repository:${repo}:pull`; // 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); if (scope) tokenUrl.searchParams.set('scope', scope); const tokenHeaders: Record = { 'User-Agent': 'Dockhand/1.0' }; // Add Basic auth header if we have credentials if (credentials) { const basicAuth = Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64'); tokenHeaders['Authorization'] = `Basic ${basicAuth}`; } const tokenResponse = await fetch(tokenUrl.toString(), { headers: tokenHeaders }); if (!tokenResponse.ok) { await tokenResponse.text(); // Consume body to release socket console.error(`Token request failed: ${tokenResponse.status}`); return null; } const tokenData = await tokenResponse.json() as { token?: string; access_token?: string }; const token = tokenData.token || tokenData.access_token || null; return token ? `Bearer ${token}` : null; } catch (e) { const errorMsg = e instanceof Error ? e.message : String(e); const cause = (e as any)?.cause; const causeMsg = cause ? ` (cause: ${cause})` : ''; console.error('[Registry] Failed to get bearer token:', errorMsg + causeMsg); const causeStr = String(cause ?? errorMsg); if (causeStr.includes('EAI_AGAIN') || causeStr.includes('ENOTFOUND')) { console.error('[Registry] DNS resolution failed. If you are on a NAS (Synology, uGreen, QNAP), try adding --dns=8.8.8.8 to your docker run command or set {"dns": ["8.8.8.8"]} in /etc/docker/daemon.json'); } return null; } } /** * Get authentication header for registry API requests. * Handles the Docker Registry V2 OAuth2 token flow: * 1. Challenge request to /v2/ to get WWW-Authenticate header * 2. Parse realm, service from challenge * 3. Request token from realm with credentials (if available) * * @param registryUrl - Full registry URL (e.g., 'https://ghcr.io' or 'https://registry.example.com/org') * @param scope - Token scope (e.g., 'registry:catalog:*' or 'repository:user/repo:pull') * @param credentials - Optional credentials { username, password } * @returns Authorization header value (e.g., 'Bearer xxx' or 'Basic xxx') or null */ export async function getRegistryAuthHeader( registryUrl: string, scope: string, credentials?: { username: string; password: string } | null ): Promise { try { // Parse URL to extract host (V2 API is always at the host root) const parsed = parseRegistryUrl(registryUrl); const apiBaseUrl = `${parsed.protocol}://${parsed.host}`; // Step 1: Challenge request to /v2/ (always at registry root, not under org path) const challengeResponse = await fetch(`${apiBaseUrl}/v2/`, { method: 'GET', headers: { 'User-Agent': 'Dockhand/1.0' } }); // If 200, no auth needed if (challengeResponse.ok) { await drainResponse(challengeResponse); return null; } // If not 401, something else is wrong if (challengeResponse.status !== 401) { await drainResponse(challengeResponse); console.error(`Registry challenge failed: ${challengeResponse.status}`); return null; } // Step 2: Parse WWW-Authenticate header const wwwAuth = challengeResponse.headers.get('WWW-Authenticate') || ''; const challenge = wwwAuth.toLowerCase(); if (challenge.startsWith('basic')) { // Basic auth - use credentials if we have them await drainResponse(challengeResponse); if (credentials) { const basicAuth = Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64'); return `Basic ${basicAuth}`; } return null; } if (!challenge.startsWith('bearer')) { await drainResponse(challengeResponse); console.error(`Unsupported auth type: ${wwwAuth}`); return null; } // Drain 401 response body before bearer token fetch (required by Node.js/Undici for connection reuse) await drainResponse(challengeResponse); // Parse bearer challenge: Bearer realm="...",service="...",scope="..." const realmMatch = wwwAuth.match(/realm="([^"]+)"/i); const serviceMatch = wwwAuth.match(/service="([^"]+)"/i); if (!realmMatch) { console.error('No realm in WWW-Authenticate header'); return null; } const realm = realmMatch[1]; const service = serviceMatch ? serviceMatch[1] : ''; // 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); if (scope) tokenUrl.searchParams.set('scope', scope); const tokenHeaders: Record = { 'User-Agent': 'Dockhand/1.0' }; // Add Basic auth header if we have credentials if (credentials) { const basicAuth = Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64'); tokenHeaders['Authorization'] = `Basic ${basicAuth}`; } const tokenResponse = await fetch(tokenUrl.toString(), { headers: tokenHeaders }); if (!tokenResponse.ok) { const errorBody = await tokenResponse.text().catch(() => ''); console.error(`Token request failed: ${tokenResponse.status} - ${errorBody}`); return null; } const tokenData = await tokenResponse.json() as { token?: string; access_token?: string }; const token = tokenData.token || tokenData.access_token || null; return token ? `Bearer ${token}` : null; } catch (e) { const errorMsg = e instanceof Error ? e.message : String(e); const cause = (e as any)?.cause; const causeMsg = cause ? ` (cause: ${cause})` : ''; console.error('[Registry] Failed to get auth header:', errorMsg + causeMsg); return null; } } /** * Helper to get normalized registry URL and auth header for registry API requests. * Combines URL normalization, credential extraction, and token flow in one call. * * @param registry - Registry object from database * @param scope - Token scope (e.g., 'registry:catalog:*' or 'repository:user/repo:pull') * @returns { baseUrl, orgPath, authHeader } - Base URL (host only for V2 API), org path, and auth header */ export async function getRegistryAuth( registry: { url: string; username?: string | null; password?: string | null }, scope: string ): Promise<{ baseUrl: string; orgPath: string; authHeader: string | null }> { // Parse registry URL to extract host and organization path const parsed = parseRegistryUrl(registry.url); // V2 API endpoints are always at the registry host root const baseUrl = `${parsed.protocol}://${parsed.host}`; // Get auth header using proper token flow const credentials = registry.username && registry.password ? { username: registry.username, password: registry.password } : null; const authHeader = await getRegistryAuthHeader(registry.url, scope, credentials); return { baseUrl, orgPath: parsed.path, authHeader }; } // Catalog-failure classification lives in an import-light module so it's unit-testable // without pulling this file's better-sqlite3 imports into the test process (#873). export { classifyCatalogFailure, CATALOG_NOT_SUPPORTED_MSG } from './registry-catalog.js'; // --- Harbor fallback for catalog and image search --- // Harbor denies access to the V2 _catalog endpoint for robot accounts. // We detect Harbor and use the native project API as a fallback. /** Harbor detection cache per host (TTL 5 min). Only definitive (non-error) detections are cached. */ const harborDetectionCache = new Map(); const HARBOR_CACHE_TTL = 5 * 60 * 1000; export interface HarborCatalogResult { repositories: string[]; /** Pagination cursor: "harbor:" or null if last page */ nextLast: string | null; } /** * Detects whether a registry is a Harbor instance. * Checks for service="harbor-registry" in the WWW-Authenticate header from /v2/, * then confirms via /api/v2.0/ping. Result is cached for 5 min per host. */ export async function isHarborRegistry(registryUrl: string): Promise { const parsed = parseRegistryUrl(registryUrl); const host = parsed.host; const cached = harborDetectionCache.get(host); if (cached && Date.now() - cached.ts < HARBOR_CACHE_TTL) { return cached.isHarbor; } // Respect the registry's configured scheme and port (parsed.host includes the port). const baseUrl = `${parsed.protocol}://${parsed.host}`; let isHarbor = false; try { // Step 1: check the WWW-Authenticate header from /v2/ const challengeResp = await fetch(`${baseUrl}/v2/`, { method: 'GET', headers: { 'User-Agent': 'Dockhand/1.0' } }); const wwwAuth = challengeResp.headers.get('WWW-Authenticate') || ''; if (wwwAuth.toLowerCase().includes('service="harbor-registry"')) { // Step 2: confirm via /api/v2.0/ping const pingResp = await fetch(`${baseUrl}/api/v2.0/ping`, { method: 'GET', headers: { 'User-Agent': 'Dockhand/1.0' } }); if (pingResp.ok) { const body = await pingResp.text(); if (body.includes('Pong')) { isHarbor = true; } } } // A definitive answer was obtained (no network error): cache it. harborDetectionCache.set(host, { isHarbor, ts: Date.now() }); } catch { // Network error: detection is indeterminate. Do NOT cache, so a transient // outage can't pin "not Harbor" for the whole TTL and keep returning 403s // once Harbor recovers. } return isHarbor; } /** * Builds the Basic auth header for the Harbor API from a registry object. * Credentials are trimmed: pasted values often carry trailing whitespace that * silently breaks Basic auth. */ function getHarborBasicAuth(registry: { username?: string | null; password?: string | null }): string | null { const username = registry.username?.trim(); const password = registry.password?.trim(); if (username && password) { return `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; } return null; } /** Builds the common headers (JSON + Basic auth) for Harbor API requests. */ function harborHeaders(registry: { username?: string | null; password?: string | null }): Record { const headers: Record = { 'Accept': 'application/json', 'User-Agent': 'Dockhand/1.0' }; const authHeader = getHarborBasicAuth(registry); if (authHeader) headers['Authorization'] = authHeader; return headers; } /** Removes Harbor query-grammar control characters so a search term can't break or alter the q filter. */ function sanitizeHarborQueryTerm(term: string): string { // Harbor's query grammar uses , = ~ ( ) as control characters. return term.replace(/[,=~()]/g, ''); } /** * Follows Harbor list-endpoint pagination, collecting every item's name. * Terminates on a short page or once X-Total-Count is reached; a safety cap * bounds pathological servers (a warning is logged if it is hit). */ async function harborPaginateNames( urlForPage: (page: number, pageSize: number) => string, headers: Record, options: { throwOnFirstError: boolean; label: string } ): Promise { const names: string[] = []; const pageSize = 100; const maxPages = 100; // ~10k items: defensive bound against a server that never returns a short page let total = 0; let page = 1; while (page <= maxPages) { const resp = await fetch(urlForPage(page, pageSize), { headers }); if (!resp.ok) { if (page === 1 && options.throwOnFirstError) { throw new Error(`Harbor API error ${resp.status} while ${options.label}`); } break; } if (page === 1) total = parseInt(resp.headers.get('X-Total-Count') || '0', 10); const items: Array<{ name: string }> = await resp.json(); for (const item of items) names.push(item.name); if (items.length < pageSize) break; if (total > 0 && names.length >= total) break; page++; } if (page > maxPages) { console.warn(`[Harbor] Pagination safety cap (${maxPages} pages) reached while ${options.label}; the list may be truncated`); } return names; } /** Enumerates the names of all Harbor projects the account can access. */ async function harborListAllProjects(baseUrl: string, headers: Record): Promise { return harborPaginateNames( (page, size) => `${baseUrl}/projects?page=${page}&page_size=${size}`, headers, { throwOnFirstError: true, label: 'listing projects' } ); } /** Enumerates the names of all repositories within a single Harbor project. */ async function harborListProjectRepositories(baseUrl: string, project: string, headers: Record): Promise { return harborPaginateNames( (page, size) => `${baseUrl}/projects/${encodeURIComponent(project)}/repositories?page=${page}&page_size=${size}`, headers, { throwOnFirstError: false, label: `listing repositories of project ${project}` } ); } /** * Lists repositories via the Harbor project API. * With orgPath set, paginates a single project natively (using X-Total-Count). * Without orgPath, enumerates every accessible project and all of their * repositories, then paginates the flattened list so the UI gets a correct * hasMore signal instead of a silently truncated first page. * @param page - page number (1-based) * @param pageSize - number of results per page */ export async function harborListRepositories( registry: { url: string; username?: string | null; password?: string | null }, orgPath: string, page: number = 1, pageSize: number = 100 ): Promise { const parsed = parseRegistryUrl(registry.url); const baseUrl = `${parsed.protocol}://${parsed.host}/api/v2.0`; const headers = harborHeaders(registry); if (orgPath) { // Single project: native page-based pagination. const project = orgPath.replace(/^\//, ''); const url = `${baseUrl}/projects/${encodeURIComponent(project)}/repositories?page=${page}&page_size=${pageSize}`; const resp = await fetch(url, { headers }); if (!resp.ok) { throw new Error(`Harbor API error ${resp.status} for project ${project}`); } const totalCount = parseInt(resp.headers.get('X-Total-Count') || '0', 10); const repos: Array<{ name: string }> = await resp.json(); const repositories = repos.map(r => r.name); const hasMore = page * pageSize < totalCount; return { repositories, nextLast: hasMore ? `harbor:${page + 1}` : null }; } // No orgPath: enumerate all projects and all of their repositories, then // slice the flattened list for the requested page. const projects = await harborListAllProjects(baseUrl, headers); const all: string[] = []; for (const project of projects) { const repos = await harborListProjectRepositories(baseUrl, project, headers); for (const name of repos) all.push(name); } const start = (page - 1) * pageSize; const repositories = all.slice(start, start + pageSize); const hasMore = start + pageSize < all.length; return { repositories, nextLast: hasMore ? `harbor:${page + 1}` : null }; } /** * Searches repositories via the Harbor API using filter q=name=~{term}. * Iterates through all accessible projects (or a single one if orgPath is set). * The term is sanitized for the Harbor query grammar; a client-side substring * check on the original term keeps the results precise. */ export async function harborSearchRepositories( registry: { url: string; username?: string | null; password?: string | null }, term: string, orgPath: string, limit: number = 25 ): Promise { const parsed = parseRegistryUrl(registry.url); const baseUrl = `${parsed.protocol}://${parsed.host}/api/v2.0`; const headers = harborHeaders(registry); const termLower = term.toLowerCase(); const safeTerm = sanitizeHarborQueryTerm(term); const results: string[] = []; const projectNames = orgPath ? [orgPath.replace(/^\//, '')] : await harborListAllProjects(baseUrl, headers); // Search each project using the Harbor filter for (const project of projectNames) { if (results.length >= limit) break; const q = encodeURIComponent(`name=~${safeTerm}`); const url = `${baseUrl}/projects/${encodeURIComponent(project)}/repositories?q=${q}&page=1&page_size=${limit}`; const resp = await fetch(url, { headers }); if (!resp.ok) continue; const repos: Array<{ name: string }> = await resp.json(); for (const r of repos) { // The server filter ran on the sanitized term, so re-check the original // term client-side to guarantee precise substring matches. if (r.name.toLowerCase().includes(termLower)) { results.push(r.name); if (results.length >= limit) break; } } } return results; } /** * Check the registry for the current manifest digest of an image. * Simple HEAD request to get Docker-Content-Digest header. * Docker stores the manifest list digest in RepoDigests, so we compare that directly. */ export async function getRegistryManifestDigest(imageName: string): Promise { try { const { registry, repo, tag } = parseImageReference(imageName); const token = await getRegistryBearerToken(registry, repo); const manifestUrl = `https://${registry}/v2/${repo}/manifests/${tag}`; const headers: Record = { 'User-Agent': 'Dockhand/1.0', 'Accept': [ 'application/vnd.docker.distribution.manifest.list.v2+json', 'application/vnd.oci.image.index.v1+json', 'application/vnd.docker.distribution.manifest.v2+json', 'application/vnd.oci.image.manifest.v1+json' ].join(', ') }; if (token) headers['Authorization'] = token; const response = await fetch(manifestUrl, { method: 'HEAD', headers }); if (!response.ok) { await drainResponse(response); if (response.status === 429) { const retryAfter = response.headers.get('Retry-After'); console.warn(`[Registry] ${imageName}: rate limited (429)${retryAfter ? `, retry after ${retryAfter}s` : ''}`); } else { console.error(`[Registry] ${imageName}: ${response.status}`); } return null; } const digest = response.headers.get('Docker-Content-Digest'); await drainResponse(response); return digest; } catch (e) { const causeStr = String((e as any)?.cause ?? e); if (causeStr.includes('EAI_AGAIN') || causeStr.includes('ENOTFOUND')) { console.error(`[Registry] ${imageName}: DNS resolution failed. If you are on a NAS (Synology, uGreen, QNAP), add --dns=8.8.8.8 to your docker run command.`); } else { console.error(`[Registry] ${imageName}: ${e}`); } return null; } } export interface ImageUpdateCheckResult { hasUpdate: boolean; currentDigest?: string; registryDigest?: string; /** True if this is a local-only image (no registry) */ isLocalImage?: boolean; /** Error message if check failed */ error?: string; } /** * Check if an image has an update available by comparing local digests against registry. * This is a lightweight check that doesn't pull the image. * * @param imageName - The image name with optional tag (e.g., "nginx:latest") * @param currentImageId - The sha256 ID of the current local image * @param envId - Optional environment ID for multi-environment support * @returns Update check result with hasUpdate flag and digest info */ export async function checkImageUpdateAvailable( imageName: string, currentImageId: string, envId?: number ): Promise { try { // Skip update check for digest-pinned images // If the user explicitly pins to a digest (image@sha256:...), they don't want auto-updates if (isDigestBasedImage(imageName)) { return { hasUpdate: false, currentDigest: imageName.split('@')[1] // Extract the digest part }; } // 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 { currentImageInfo = await inspectImage(currentImageId, envId); } catch { return { hasUpdate: false, error: 'Could not inspect current image' }; } const currentRepoDigests: string[] = currentImageInfo?.RepoDigests || []; // Extract digest part from RepoDigest (format: repo@sha256:...) const extractDigest = (rd: string): string | null => { const atIndex = rd.lastIndexOf('@'); return atIndex > -1 ? rd.substring(atIndex + 1) : null; }; // Get ALL local digests - an image can have multiple RepoDigests // (e.g., when a tag is updated but the content for your architecture is the same) const localDigests = currentRepoDigests .map(extractDigest) .filter((d): d is string => d !== null); // The RUNNING image has no repo digests. That is ambiguous: it can be a // genuinely local/built image (no registry to check against), OR a registry // image whose tag has since moved to a newer pull — leaving the container // running an old, now-untagged, digest-less image (#1288). The old code // declared "local" here and skipped the check forever, which is exactly the // state that means "this container needs updating". // // Distinguish by asking the registry for imageName's tag (imageName is the // container's Config.Image, e.g. "rotki/rotki:latest"): if the registry // answers, this IS a registry image, and since the running image can't match // a digest we don't have, an update is available. If the registry can't // resolve it (404 / unreachable / truly local), THEN treat it as local. if (localDigests.length === 0) { const registryDigest = await getRegistryManifestDigest(imageName); const { hasUpdate, isLocalImage, registryDigest: rd } = classifyEmptyDigestImage(registryDigest); return { hasUpdate, isLocalImage, currentDigest: currentImageId, registryDigest: rd }; } // Query registry for current manifest digest const registryDigest = await getRegistryManifestDigest(imageName); if (!registryDigest) { // Registry unreachable or image not found - can't determine update status return { hasUpdate: false, currentDigest: currentRepoDigests[0], error: 'Could not query registry' }; } // Check if registry digest matches ANY of the local digests const matchesLocal = localDigests.includes(registryDigest); const hasUpdate = !matchesLocal; return { hasUpdate, currentDigest: currentRepoDigests[0], registryDigest: hasUpdate ? registryDigest : undefined }; } catch (e: any) { return { hasUpdate: false, error: e.message }; } } export async function tagImage(id: string, repo: string, tag: string, envId?: number | null) { const response = await dockerFetch( `/images/${encodeURIComponent(id)}/tag?repo=${encodeURIComponent(repo)}&tag=${encodeURIComponent(tag)}`, { method: 'POST' }, envId ); await assertDockerResponse(response); } /** * Generate a temporary tag name for safe pulling during auto-updates. * This allows scanning the new image before committing to the update. * @param imageName - The original image name (e.g., "nginx:latest" or "nginx") * @returns Temporary tag name (e.g., "nginx:latest-dockhand-pending") */ export function getTempImageTag(imageName: string): string { // Handle images with digest (e.g., nginx@sha256:abc123) if (imageName.includes('@')) { // For digest-based images, we can't use temp tags - return as-is return imageName; } // Find the last colon const lastColon = imageName.lastIndexOf(':'); // No colon at all - simple image like "nginx" if (lastColon === -1) { return `${imageName}:latest-dockhand-pending`; } const afterColon = imageName.substring(lastColon + 1); // If the part after the last colon contains a slash, it's a port number // e.g., "registry:5000/nginx" -> afterColon = "5000/nginx" // In this case, there's no tag, so we append :latest-dockhand-pending if (afterColon.includes('/')) { return `${imageName}:latest-dockhand-pending`; } // Otherwise, the last colon separates repo from tag // e.g., "registry.bor6.pl/test:latest" -> repo="registry.bor6.pl/test", tag="latest" const repo = imageName.substring(0, lastColon); const tag = afterColon; return `${repo}:${tag}-dockhand-pending`; } /** * Check if an image name is using a digest (sha256) instead of a tag. * Digest-based images don't need temp tag handling. */ export function isDigestBasedImage(imageName: string): boolean { return imageName.includes('@sha256:'); } /** * Normalize an image tag for comparison. * Docker Hub images can be represented as: * - n8nio/n8n:latest * - docker.io/n8nio/n8n:latest * - docker.io/library/nginx:latest (for official images) * - library/nginx:latest * - nginx:latest * Custom registries: * - docker.n8n.io/n8nio/n8n (n8n's custom registry) */ function normalizeImageTag(tag: string): string { let normalized = tag; // Remove docker.io/ prefix normalized = normalized.replace(/^docker\.io\//, ''); // Remove library/ prefix for official images normalized = normalized.replace(/^library\//, ''); // Add :latest if no tag specified (and not a digest) if (!normalized.includes(':') && !normalized.includes('@')) { normalized = `${normalized}:latest`; } return normalized.toLowerCase(); } /** * Get image ID by tag name. * Uses Docker's image inspect API which correctly resolves any image reference * (docker.io, ghcr.io, custom registries, etc.) * @returns Image ID (sha256:...) or null if not found */ export async function getImageIdByTag(tagName: string, envId?: number | null): Promise { try { // First try: Use Docker's image inspect API - this is the most reliable // as Docker knows exactly how to resolve the image name const imageInfo = await inspectImage(tagName, envId) as { Id?: string } | null; if (imageInfo?.Id) { return imageInfo.Id; } } catch { // Image inspect failed - fall back to listing images } try { // Fallback: Search through listed images with normalization const images = await listImages(envId); const normalizedSearch = normalizeImageTag(tagName); for (const image of images) { if (image.tags) { for (const tag of image.tags) { if (normalizeImageTag(tag) === normalizedSearch) { return image.id; } } } } return null; } catch { return null; } } /** * Remove a temporary image by its tag. * Used to clean up after a blocked auto-update. * @param imageIdOrTag - Image ID or tag to remove * @param force - Force removal even if image is in use */ export async function removeTempImage(imageIdOrTag: string, envId?: number | null, force = true): Promise { try { await removeImage(imageIdOrTag, force, envId); } catch (error: any) { // Log but don't throw - cleanup failure shouldn't break the flow console.warn(`[Docker] Failed to remove temp image ${imageIdOrTag}: ${error.message}`); } } /** * Export (save) an image as a tar archive stream. * Uses Docker's GET /images/{name}/get endpoint. * @returns Response object with tar stream body */ export async function exportImage(id: string, envId?: number | null): Promise { const response = await dockerFetch( `/images/${encodeURIComponent(id)}/get`, { method: 'GET', streaming: true }, envId ); if (!response.ok) { const error = await response.text().catch(() => 'Unknown error'); throw new Error(`Failed to export image: ${response.status} - ${error}`); } return response; } // System information export async function getDockerInfo(envId?: number | null) { return dockerJsonRequest('/info', {}, envId); } export async function getDockerVersion(envId?: number | null) { return dockerJsonRequest('/version', {}, envId); } /** * Get the Docker daemon's API version string for a given environment. * Used to pin DOCKER_API_VERSION when spawning sidecar containers (scanner, updater) * whose bundled Docker CLI may be newer than the host daemon supports. * Returns null if the version cannot be determined. * Requires an envId — local Docker (no environment) must query /version directly. */ export async function getNegotiatedApiVersion(envId: number): Promise { try { const versionInfo = await getDockerVersion(envId); return versionInfo?.ApiVersion || null; } catch { return null; } } /** * Lightweight ping check for Docker daemon availability. * Uses /_ping endpoint which returns "OK" as plain text with minimal overhead. * Used by the circuit breaker to probe offline environments. */ export async function dockerPing(envId: number): Promise { try { // Edge connections go WebSocket → agent → Docker daemon, which adds latency on slow hosts. // Use a longer timeout for edge to avoid false negatives on overloaded NAS/VPS devices. const config = await getDockerConfig(envId).catch(() => null); const timeoutMs = config?.connectionType === 'hawser-edge' ? 20000 : 5000; const response = await dockerFetch('/_ping', { signal: AbortSignal.timeout(timeoutMs) }, envId); await drainResponse(response); return response.ok; } catch (error: any) { const msg = error?.message || String(error); if (msg.includes('unreachable')) { console.warn(`[Docker] ${config?.connectionType || 'direct'} ${config?.host || envId}: /_ping failed - host unreachable`); } return false; } } /** * Get Hawser agent info (for hawser-standard mode) * Returns agent info including uptime */ export async function getHawserInfo(envId: number): Promise<{ agentId: string; agentName: string; dockerVersion: string; hawserVersion: string; mode: string; uptime: number; } | null> { for (let attempt = 0; attempt < 2; attempt++) { try { const response = await dockerFetch('/_hawser/info', {}, envId); if (response.ok) { return await response.json(); } await drainResponse(response); console.warn(`[Hawser] Info endpoint returned ${response.status} for env ${envId}`); } catch (error) { const msg = error instanceof Error ? error.message : String(error); console.warn(`[Hawser] Failed to fetch info for env ${envId} (attempt ${attempt + 1}): ${msg}`); } } return null; } // Volume operations export interface VolumeInfo { name: string; driver: string; mountpoint: string; scope: string; created: string; labels: { [key: string]: string }; /** Containers currently referencing this volume (running or stopped); empty = unused. */ usedBy: Array<{ containerId: string; containerName: string }>; /** driver_opts (e.g. NFS/CIFS type+device+o), only when non-empty. */ options?: Record; } export async function listVolumes(envId?: number | null): Promise { // Fetch volumes and containers in parallel const [volumeResult, containers] = await Promise.all([ dockerJsonRequest<{ Volumes: any[] }>('/volumes', {}, envId), dockerJsonRequest('/containers/json?all=true', {}, envId) ]); // Build a map of volume name -> containers using it const volumeUsageMap = new Map(); for (const container of containers) { const containerName = container.Names?.[0]?.replace(/^\//, '') || 'unnamed'; const containerId = container.Id; for (const mount of container.Mounts || []) { // Check for volume-type mounts (not bind mounts) if (mount.Type === 'volume' && mount.Name) { const volumeName = mount.Name; if (!volumeUsageMap.has(volumeName)) { volumeUsageMap.set(volumeName, []); } volumeUsageMap.get(volumeName)!.push({ containerId, containerName }); } } } return (volumeResult.Volumes || []).map((volume: any) => ({ name: volume.Name, driver: volume.Driver, mountpoint: volume.Mountpoint, scope: volume.Scope, created: volume.CreatedAt, labels: volume.Labels || {}, 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 })); } /** * Check if a volume is in use by any containers * Returns list of containers using the volume */ export async function getVolumeUsage( volumeName: string, envId?: number | null ): Promise<{ containerId: string; containerName: string; state: string }[]> { const containers = await dockerJsonRequest('/containers/json?all=true', {}, envId); const usage: { containerId: string; containerName: string; state: string }[] = []; for (const container of containers) { // Skip our own helper containers if (container.Labels?.['dockhand.volume.helper'] === 'true') { continue; } const containerName = container.Names?.[0]?.replace(/^\//, '') || 'unnamed'; const containerId = container.Id; const state = container.State || 'unknown'; for (const mount of container.Mounts || []) { if (mount.Type === 'volume' && mount.Name === volumeName) { usage.push({ containerId, containerName, state }); break; } } } return usage; } export async function removeVolume(name: string, force = false, envId?: number | null) { const response = await dockerFetch(`/volumes/${encodeURIComponent(name)}?force=${force}`, { method: 'DELETE' }, envId); if (!response.ok) { const data = await response.json().catch(() => ({})); const error: any = new Error(data.message || 'Failed to remove volume'); error.statusCode = response.status; error.json = data; throw error; } await drainResponse(response); } export async function inspectVolume(name: string, envId?: number | null) { return dockerJsonRequest(`/volumes/${encodeURIComponent(name)}`, {}, envId); } export interface CreateVolumeOptions { name: string; driver?: string; driverOpts?: { [key: string]: string }; labels?: { [key: string]: string }; } export async function createVolume(options: CreateVolumeOptions, envId?: number | null) { const volumeConfig = { Name: options.name, Driver: options.driver || 'local', DriverOpts: options.driverOpts || {}, Labels: options.labels || {} }; return dockerJsonRequest('/volumes/create', { method: 'POST', body: JSON.stringify(volumeConfig) }, envId); } // Network operations export interface NetworkInfo { id: string; name: string; driver: string; scope: string; internal: boolean; ipam: { driver: string; config: Array<{ subnet?: string; gateway?: string }>; }; containers: { [key: string]: { name: string; ipv4Address: string } }; } export async function listNetworks(envId?: number | null): Promise { const [networks, containers] = await Promise.all([ dockerJsonRequest('/networks', {}, envId), dockerJsonRequest('/containers/json?all=true', {}, envId) ]); // Build map of networkId -> container info from container network settings const networkContainers = new Map>(); for (const container of containers) { const nets = container.NetworkSettings?.Networks; if (!nets) continue; const containerName = (container.Names?.[0] || '').replace(/^\//, ''); for (const [, netInfo] of Object.entries(nets as Record)) { const netId = netInfo.NetworkID; if (!netId) continue; if (!networkContainers.has(netId)) { networkContainers.set(netId, {}); } networkContainers.get(netId)![container.Id] = { name: containerName, ipv4Address: netInfo.IPAddress || '' }; } } return networks.map((network: any) => ({ id: network.Id, name: network.Name, driver: network.Driver, scope: network.Scope, internal: network.Internal || false, ipam: { driver: network.IPAM?.Driver || 'default', // Normalize IPAM config field names to lowercase for consistency config: (network.IPAM?.Config || []).map((cfg: any) => ({ subnet: cfg.Subnet || cfg.subnet, gateway: cfg.Gateway || cfg.gateway, ipRange: cfg.IPRange || cfg.ipRange, auxAddress: cfg.AuxAddress || cfg.auxAddress })) }, containers: networkContainers.get(network.Id) || {} })); } export async function removeNetwork(id: string, envId?: number | null) { const response = await dockerFetch(`/networks/${id}`, { method: 'DELETE' }, envId); if (!response.ok) { const data = await response.json().catch(() => ({})); const error: any = new Error(data.message || 'Failed to remove network'); error.statusCode = response.status; error.json = data; throw error; } await drainResponse(response); } export async function inspectNetwork(id: string, envId?: number | null) { return dockerJsonRequest(`/networks/${id}`, {}, envId); } export interface CreateNetworkOptions { name: string; driver?: string; internal?: boolean; attachable?: boolean; ingress?: boolean; enableIPv6?: boolean; ipam?: { driver?: string; config?: Array<{ subnet?: string; ipRange?: string; gateway?: string; auxAddress?: { [key: string]: string }; }>; options?: { [key: string]: string }; }; options?: { [key: string]: string }; labels?: { [key: string]: string }; } export async function createNetwork(options: CreateNetworkOptions, envId?: number | null) { const networkConfig: any = { Name: options.name, Driver: options.driver || 'bridge', Internal: options.internal || false, Attachable: options.attachable || false, Ingress: options.ingress || false, EnableIPv6: options.enableIPv6 || false, Options: options.options || {}, Labels: options.labels || {} }; if (options.ipam) { networkConfig.IPAM = { Driver: options.ipam.driver || 'default', Config: options.ipam.config?.map(cfg => ({ Subnet: cfg.subnet, IPRange: cfg.ipRange, Gateway: cfg.gateway, AuxiliaryAddresses: cfg.auxAddress })).filter(cfg => cfg.Subnet || cfg.Gateway) || [], Options: options.ipam.options || {} }; } return dockerJsonRequest('/networks/create', { method: 'POST', body: JSON.stringify(networkConfig) }, envId); } // Network connect/disconnect operations export interface NetworkConnectOptions { aliases?: string[]; ipv4Address?: string; ipv6Address?: string; gwPriority?: number; } export async function connectContainerToNetwork( networkId: string, containerId: string, envId?: number | null, options?: NetworkConnectOptions ): Promise { const body: any = { Container: containerId }; // Add EndpointConfig for aliases, static IP, and gateway priority if (options?.aliases || options?.ipv4Address || options?.ipv6Address || options?.gwPriority !== undefined) { body.EndpointConfig = {}; if (options.aliases && options.aliases.length > 0) { body.EndpointConfig.Aliases = options.aliases; } if (options.ipv4Address || options.ipv6Address) { body.EndpointConfig.IPAMConfig = {}; if (options.ipv4Address) { body.EndpointConfig.IPAMConfig.IPv4Address = options.ipv4Address; } if (options.ipv6Address) { body.EndpointConfig.IPAMConfig.IPv6Address = options.ipv6Address; } } // Gateway priority (Docker Engine 28+) if (options.gwPriority !== undefined) { body.EndpointConfig.GwPriority = options.gwPriority; } } const response = await dockerFetch( `/networks/${networkId}/connect`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }, envId ); if (!response.ok) { const data = await response.json().catch(() => ({})); throw new Error(data.message || 'Failed to connect container to network'); } await drainResponse(response); } /** * Connect a container to a network using a raw EndpointSettings object from inspect data. * Passes the full EndpointSettings as-is, preserving all fields (Links, DriverOpts, * IPAMConfig.LinkLocalIPs, MacAddress, etc.) without manual field extraction. */ export async function connectContainerToNetworkRaw( networkId: string, containerId: string, endpointSettings: any, envId?: number | null ): Promise { const body: any = { Container: containerId, EndpointConfig: endpointSettings }; const response = await dockerFetch( `/networks/${networkId}/connect`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }, envId ); if (!response.ok) { const data = await response.json().catch(() => ({})); throw new Error(data.message || 'Failed to connect container to network'); } await drainResponse(response); } export async function disconnectContainerFromNetwork( networkId: string, containerId: string, force = false, envId?: number | null ): Promise { const response = await dockerFetch( `/networks/${networkId}/disconnect`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ Container: containerId, Force: force }) }, envId ); if (!response.ok) { const data = await response.json().catch(() => ({})); throw new Error(data.message || 'Failed to disconnect container from network'); } await drainResponse(response); } // Container exec operations export interface ExecOptions { containerId: string; cmd: string[]; user?: string; workingDir?: string; envId?: number | null; } export async function createExec(options: ExecOptions): Promise<{ Id: string }> { const execConfig = { Cmd: options.cmd, AttachStdin: true, AttachStdout: true, AttachStderr: true, Tty: true, User: options.user || 'root', WorkingDir: options.workingDir }; return dockerJsonRequest(`/containers/${options.containerId}/exec`, { method: 'POST', body: JSON.stringify(execConfig) }, options.envId); } export async function resizeExec(execId: string, cols: number, rows: number, envId?: number | null) { try { const response = await dockerFetch(`/exec/${execId}/resize?h=${rows}&w=${cols}`, { method: 'POST' }, envId); await drainResponse(response); } catch { // Resize may fail if exec is not running, ignore } } /** * Get Docker connection info for direct WebSocket connections from the client * This is used by the terminal to connect directly to the Docker API */ export async function getDockerConnectionInfo(envId?: number | null): Promise<{ type: 'socket' | 'http' | 'https'; socketPath?: string; host?: string; port?: number; }> { const config = await getDockerConfig(envId); return { type: config.type, socketPath: config.socketPath, host: config.host, port: config.port }; } // ============================================================================= // Global handlers for server.js terminal WebSocket connections // ============================================================================= // server.js cannot import SvelteKit modules directly, so we expose these // functions via globalThis (same pattern as Hawser handlers). // ============================================================================= declare global { var __terminalGetTarget: ((envId?: number) => Promise<{ type: 'socket' | 'http' | 'https'; connectionType?: string; socketPath?: string; host?: string; port?: number; hawserToken?: string; environmentId?: number; tls?: { ca?: string; cert?: string; key?: string; rejectUnauthorized: boolean }; }>) | undefined; var __terminalCreateExec: ((containerId: string, shell: string, user: string, envId?: number) => Promise) | undefined; var __terminalResizeExec: ((execId: string, cols: number, rows: number, envId?: number) => Promise) | undefined; } globalThis.__terminalGetTarget = async (envId?: number) => { if (!envId) { // No environment = local socket return { type: 'socket', connectionType: 'socket', socketPath: '/var/run/docker.sock' }; } const config = await getDockerConfig(envId); const result: Awaited>> = { type: config.type, connectionType: config.connectionType, socketPath: config.socketPath, host: config.host, port: config.port, hawserToken: config.hawserToken, environmentId: config.environmentId, }; if (config.type === 'https') { result.tls = { ca: config.ca, cert: config.cert, key: config.key, rejectUnauthorized: !config.skipVerify, }; } return result; }; globalThis.__terminalCreateExec = async (containerId, shell, user, envId) => { const exec = await createExec({ containerId, cmd: [shell], user, envId }); return exec.Id; }; globalThis.__terminalResizeExec = async (execId, cols, rows, envId) => { await resizeExec(execId, cols, rows, envId); }; // System disk usage export async function getDiskUsage(envId?: number | null) { return dockerJsonRequest('/system/df', {}, envId); } // Prune operations export async function pruneContainers(envId?: number | null) { return dockerJsonRequest('/containers/prune', { method: 'POST' }, envId); } export async function pruneImages(dangling = true, envId?: number | null) { // dangling=true: only remove untagged images. Pass straight through — // scanner images are always tagged so they can't be dangling anyway. if (dangling) { return dockerJsonRequest( `/images/prune?filters=${encodeURIComponent('{"dangling":["true"]}')}`, { method: 'POST' }, envId ); } // dangling=false: "prune all unused." When the scanner-protection setting // is on, shield grype + trivy with stopped holder containers (Docker's // "in use" check keeps them) then tear them down in a finally (#625). const protect = (await getSetting('protect_scanner_images')) !== false; if (!protect) { return dockerJsonRequest( `/images/prune?filters=${encodeURIComponent('{"dangling":["false"]}')}`, { method: 'POST' }, envId ); } const { DEFAULT_GRYPE_IMAGE, DEFAULT_TRIVY_IMAGE } = await import('./scanner'); const { DEFAULT_HELPER_IMAGE } = await import('./backups/restic'); const grypeImg = (await getSetting('default_grype_image')) ?? DEFAULT_GRYPE_IMAGE; const trivyImg = (await getSetting('default_trivy_image')) ?? DEFAULT_TRIVY_IMAGE; // The backup/restore helper image is pulled once and reused for every backup; shield it from // prune too (same holder-container trick), so an unused-image prune between backups doesn't // force a slow re-pull (and a fail on an air-tight/offline host). const helperImg = (await getSetting('default_backup_image')) || DEFAULT_HELPER_IMAGE; const holderIds: string[] = []; for (const image of [grypeImg, trivyImg, helperImg]) { try { const safeName = `dockhand-prune-keep-${image.replace(/[^a-z0-9]/gi, '-')}-${Date.now()}`; const created = await dockerJsonRequest<{ Id: string }>( `/containers/create?name=${encodeURIComponent(safeName)}`, { method: 'POST', body: JSON.stringify({ Image: image, Cmd: ['/bin/true'] }) }, envId ); holderIds.push(created.Id); } catch { // Image not present locally → nothing to protect → no-op } } try { return await dockerJsonRequest( `/images/prune?filters=${encodeURIComponent('{"dangling":["false"]}')}`, { method: 'POST' }, envId ); } finally { for (const id of holderIds) { try { await dockerJsonRequest( `/containers/${id}?force=true`, { method: 'DELETE' }, envId ); } catch { // Best-effort cleanup; orphan holders are visible but harmless } } } } export async function pruneVolumes( envId?: number | null ): Promise<{ VolumesDeleted: string[]; SpaceReclaimed: number }> { // Docker's own /volumes/prune only removes ANONYMOUS volumes since API v1.42 // (without filters={"all":["true"]}), so named dangling volumes survive — the // bug in #1289. We prune explicitly instead: list volumes, pick the unused, // unprotected ones, and remove each. This also lets us EXCLUDE the scanner // cache volumes (dockhand-grype-db / -trivy-db) by name, which a plain // all=true prune would wrongly delete. const { selectVolumesToPrune, PROTECTED_VOLUME_NAMES } = await import('./volume-prune-core'); const volumes = await listVolumes(envId); const toDelete = selectVolumesToPrune(volumes, PROTECTED_VOLUME_NAMES); const deleted: string[] = []; for (const name of toDelete) { try { // force=false: a volume that races into "in use" between the list and the // delete errors out and is skipped, rather than being force-removed — // matching Docker prune's non-forcing semantics. await removeVolume(name, false, envId); deleted.push(name); } catch (err) { console.error(`[Prune] Failed to remove volume "${name}":`, err instanceof Error ? err.message : err); // Continue — one stubborn volume must not abort the whole prune. } } // SpaceReclaimed is a placeholder 0: the /volumes list API carries no per-volume // size, so we don't fabricate a byte count. The field is kept for backward // compatibility (callers/audit and the test only check it is a number). return { VolumesDeleted: deleted, SpaceReclaimed: 0 }; } export async function pruneNetworks(envId?: number | null) { return dockerJsonRequest('/networks/prune', { method: 'POST' }, envId); } export async function pruneAll(envId?: number | null) { const containers = await pruneContainers(envId); const images = await pruneImages(false, envId); const volumes = await pruneVolumes(envId); const networks = await pruneNetworks(envId); return { containers, images, volumes, networks }; } // Registry operations export async function searchImages(term: string, limit = 25, envId?: number | null) { return dockerJsonRequest(`/images/search?term=${encodeURIComponent(term)}&limit=${limit}`, {}, envId); } // List containers with size info (slower operation) export async function listContainersWithSize(all = true, envId?: number | null): Promise> { const containers = await dockerJsonRequest( `/containers/json?all=${all}&size=true`, {}, envId ); const sizes: Record = {}; for (const container of containers) { sizes[container.Id] = { sizeRw: container.SizeRw || 0, sizeRootFs: container.SizeRootFs || 0 }; } return sizes; } // Get container top (process list) export async function getContainerTop(id: string, envId?: number | null): Promise<{ Titles: string[]; Processes: string[][] }> { return dockerJsonRequest(`/containers/${id}/top`, {}, envId); } // Execute a command in a container and return the output export async function execInContainer( containerId: string, cmd: string[], envId?: number | null, user?: string | null ): Promise { const execBody: any = { Cmd: cmd, AttachStdout: true, AttachStderr: true, Tty: false }; if (user) { execBody.User = user; } const execCreate = await dockerJsonRequest<{ Id: string }>( `/containers/${containerId}/exec`, { method: 'POST', body: JSON.stringify(execBody) }, envId ); // Start exec and get output const response = await dockerFetch( `/exec/${execCreate.Id}/start`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ Detach: false, Tty: false }) }, envId ); if (!response.ok) await throwDockerError(response); const buffer = Buffer.from(await response.arrayBuffer()); const output = demuxDockerStream(buffer) as string; // Check exit code by inspecting the exec instance const execInfo = await dockerJsonRequest<{ ExitCode: number }>( `/exec/${execCreate.Id}/json`, {}, envId ); if (execInfo.ExitCode !== 0) { const errorMsg = output.trim() || `Command failed with exit code ${execInfo.ExitCode}`; throw new Error(errorMsg); } return output; } // Get Docker events as a stream (for SSE) // For streaming mode: call with just filters // For polling mode: call with since and until to get a finite window of events export async function getDockerEvents( filters: Record, envId?: number | null, options?: { since?: string; until?: string } ): Promise | null> { const filterJson = JSON.stringify(filters); // Build query string with optional since/until for polling mode let queryString = `filters=${encodeURIComponent(filterJson)}`; if (options?.since) { queryString += `&since=${encodeURIComponent(options.since)}`; } if (options?.until) { queryString += `&until=${encodeURIComponent(options.until)}`; } try { // Use streaming: true for this long-lived connection. // The Docker events API keeps the connection open indefinitely, sending events as they occur. const response = await dockerFetch( `/events?${queryString}`, { streaming: true }, envId ); if (!response.ok) { await drainResponse(response); throw new Error(`Docker events API returned ${response.status}`); } return response.body; } catch (error: any) { throw error; } } // Check if volume exists export async function volumeExists(volumeName: string, envId?: number | null): Promise { try { const volumes = await listVolumes(envId); return volumes.some(v => v.name === volumeName); } catch { return false; } } // Generate a random suffix for container names (avoids conflicts) function randomSuffix(): string { return Math.random().toString(36).substring(2, 8); } // Run a short-lived container and return stdout export async function runContainer(options: { image: string; cmd: string[]; binds?: string[]; env?: string[]; extraHosts?: string[]; name?: string; envId?: number | null; networkMode?: string; // Docker network mode (e.g., 'host', 'bridge', custom network name) dns?: string[]; // Custom DNS servers; undefined = inherit from Docker daemon }): Promise<{ stdout: string; stderr: string }> { // Add random suffix to avoid naming conflicts const baseName = options.name || `dockhand-temp-${Date.now()}`; const containerName = `${baseName}-${randomSuffix()}`; // Create container - disable AutoRemove since we fetch logs after exit // and clean up manually. AutoRemove causes race condition where container // is removed before we can fetch logs. const containerConfig: any = { Image: options.image, Cmd: options.cmd, Env: options.env || [], Tty: false, HostConfig: { Binds: options.binds || [], AutoRemove: false // Never use AutoRemove - we clean up manually after fetching logs } }; if (options.extraHosts && options.extraHosts.length > 0) { containerConfig.HostConfig.ExtraHosts = options.extraHosts; } if (options.networkMode) { containerConfig.HostConfig.NetworkMode = options.networkMode; } if (options.dns && options.dns.length > 0) { containerConfig.HostConfig.Dns = options.dns; } const createResult = await dockerJsonRequest<{ Id: string }>( `/containers/create?name=${encodeURIComponent(containerName)}`, { method: 'POST', body: JSON.stringify(containerConfig) }, options.envId ); const containerId = createResult.Id; console.log(`[runContainer] Created container ${containerId} for image ${options.image}`); try { // Start container console.log(`[runContainer] Starting container ${containerId}...`); await assertDockerResponse(await dockerFetch(`/containers/${containerId}/start`, { method: 'POST' }, options.envId)); // Wait for container to finish console.log(`[runContainer] Waiting for container ${containerId} to finish...`); const waitResponse = await dockerFetch(`/containers/${containerId}/wait`, { method: 'POST', streaming: true }, options.envId); if (!waitResponse.ok) await throwDockerError(waitResponse); const waitResult = await waitResponse.json().catch(() => ({})); console.log(`[runContainer] Container ${containerId} finished with exit code:`, waitResult?.StatusCode); // Get logs - container is stopped but NOT removed yet since AutoRemove is false console.log(`[runContainer] Fetching logs for container ${containerId}...`); const logsResponse = await dockerFetch( `/containers/${containerId}/logs?stdout=true&stderr=true`, {}, options.envId ); if (!logsResponse.ok) await throwDockerError(logsResponse); const buffer = Buffer.from(await logsResponse.arrayBuffer()); console.log(`[runContainer] Got logs buffer, size: ${buffer.length} bytes`); const result = demuxDockerStream(buffer, { separateStreams: true }) as { stdout: string; stderr: string }; console.log(`[runContainer] Demuxed: stdout=${result.stdout.length} chars, stderr=${result.stderr.length} chars`); if (result.stdout.length === 0 && result.stderr.length === 0 && buffer.length > 0) { console.log(`[runContainer] WARNING: Buffer has data but demux returned empty. First 100 bytes:`, buffer.slice(0, 100)); } return result; } finally { // Always cleanup container manually try { await drainResponse(await dockerFetch(`/containers/${containerId}?force=true`, { method: 'DELETE' }, options.envId)); } catch { // Ignore cleanup errors } } } // Run a container with attached streams (for scanners that need real-time output) /** * DIAGNOSTIC (issue #1344): when a helper's exit code can't be determined, pull the * recent `docker events` for that container so the log shows WHAT acted on it (a * `die` with exitCode, a `kill` with the signal, a `destroy`/`remove` and its actor). * `docker events` carries `Actor.Attributes` incl. the signal and, on some daemons, the * execID/name of who issued it. Bounded, non-streaming (since..until), best-effort. */ async function logRecentContainerEvents(containerId: string, name: string, sinceISO: string | undefined, envId: number | null | undefined): Promise { try { const sinceSec = sinceISO ? Math.floor(new Date(sinceISO).getTime() / 1000) - 2 : Math.floor(Date.now() / 1000) - 120; const untilSec = Math.floor(Date.now() / 1000) + 1; const filters = encodeURIComponent(JSON.stringify({ container: [containerId] })); const resp = await dockerFetch(`/events?since=${sinceSec}&until=${untilSec}&filters=${filters}`, {}, envId); if (!resp.ok) { console.warn(`[runContainerWithStreaming] events fetch for ${name}: HTTP ${resp.status}`); return; } const body = await resp.text(); // The events endpoint returns newline-delimited JSON objects. const lines = body.split('\n').map((l) => l.trim()).filter(Boolean); if (lines.length === 0) { console.warn(`[runContainerWithStreaming] no docker events for ${name} in the window (nothing recorded who touched it)`); return; } for (const line of lines) { try { const ev = JSON.parse(line) as { status?: string; Action?: string; Actor?: { Attributes?: Record }; time?: number }; const action = ev.Action ?? ev.status ?? '(unknown)'; const attrs = ev.Actor?.Attributes ?? {}; const sig = attrs.signal ? ` signal=${attrs.signal}` : ''; const code = attrs.exitCode ? ` exitCode=${attrs.exitCode}` : ''; const exec = attrs.execID ? ` execID=${attrs.execID}` : ''; console.warn(`[runContainerWithStreaming] EVENT ${name}: ${action}${sig}${code}${exec}`); } catch { /* skip a non-JSON line */ } } } catch (err) { console.warn(`[runContainerWithStreaming] events fetch failed for ${name}: ${(err as Error).message}`); } } export async function runContainerWithStreaming(options: { image: string; cmd: string[]; binds?: string[]; env?: string[]; extraHosts?: string[]; name?: string; user?: string; envId?: number | null; onStdout?: (data: string) => void; onStderr?: (data: string) => void; timeout?: number; // Overall timeout in ms (0 or undefined = no timeout) networkMode?: string; // Docker network mode (e.g., network name for TCP access) dns?: string[]; // Custom DNS servers; undefined = inherit from Docker daemon labels?: Record; // Container labels (e.g. ownership tags for safe reaping) // Hook run after the container is created but BEFORE it starts. Used by the // backup helper to put-archive large metadata/stack files into the container // (streamed via the Docker archive API) instead of embedding them in the Cmd, // which would blow ARG_MAX. Receives the created container's id. beforeStart?: (containerId: string) => Promise; }): Promise { const baseName = options.name || `dockhand-stream-${Date.now()}`; const containerName = `${baseName}-${randomSuffix()}`; // Create container WITHOUT AutoRemove - we need to fetch logs after it exits const containerConfig: any = { Image: options.image, Cmd: options.cmd, Env: options.env || [], // Every helper spawned here (backup/restore/probe, image scanner) is ephemeral and exits // on purpose, so it must NOT fire a "Container died/exited" notification like a user's // container would. dockhand.notify=false makes the event handler skip it // (isNotifyDisabledByLabel). Caller labels win if they set notify explicitly. Labels: { 'dockhand.notify': 'false', ...(options.labels || {}) }, Tty: false, HostConfig: { Binds: options.binds || [], AutoRemove: false, LogConfig: { Type: 'json-file', Config: {} } } }; if (options.extraHosts && options.extraHosts.length > 0) { containerConfig.HostConfig.ExtraHosts = options.extraHosts; } // Set user if specified (needed for rootless Docker socket access) if (options.user) { containerConfig.User = options.user; } // Set network mode if specified (e.g., for scanner containers accessing Docker via TCP) if (options.networkMode) { containerConfig.HostConfig.NetworkMode = options.networkMode; } if (options.dns && options.dns.length > 0) { containerConfig.HostConfig.Dns = options.dns; } const createResult = await dockerJsonRequest<{ Id: string }>( `/containers/create?name=${encodeURIComponent(containerName)}`, { method: 'POST', body: JSON.stringify(containerConfig) }, options.envId ); const containerId = createResult.Id; const config = await getDockerConfig(options.envId ?? undefined); try { const doWork = async () => { // Populate the container's filesystem (e.g. put-archive metadata files) // before it starts, so the entrypoint sees them without inflating the Cmd. if (options.beforeStart) await options.beforeStart(containerId); // Start container await drainResponse(await dockerFetch(`/containers/${containerId}/start`, { method: 'POST' }, options.envId)); // DIAGNOSTIC (issue #1344): one line confirming the helper actually started, // with the config that could explain an early death (bind count, network, // user). Pairs with the "exited with code" / "EXIT-CODE INDETERMINATE" lines // so a user's log shows the full lifecycle of a helper that dies mid-run. console.log(`[runContainerWithStreaming] Started ${containerName} (binds=${(options.binds || []).length}, netMode=${options.networkMode ?? 'default'}, user=${options.user ?? 'default'})`); // Create abort controller to cancel stderr stream when container exits // On some Docker hosts (e.g. Synology NAS with older kernels), follow=true // streams don't close when the container exits, causing indefinite hangs. const abortController = new AbortController(); // Start stderr streaming (non-blocking - may hang on some hosts). When the // caller wants live stdout too (backup helper → restic --json progress), the // local path follows both streams; edge keeps stderr-only for now (its // progress still lands after exit, an acceptable fallback for the minority // edge transport). onStdout is opt-in, so all other callers are unaffected. const stderrPromise = (config.connectionType === 'hawser-edge' && config.environmentId) ? streamEdgeStderr(config.environmentId, containerId, options.onStderr, abortController.signal) : streamLocalStderr(containerId, options.envId, options.onStderr, abortController.signal, options.onStdout); stderrPromise.catch(() => {}); // Suppress unhandled rejection // Wait for the container to exit — this is the reliable exit signal. // A failed /wait must NOT be treated as success: on flaky hosts (Synology, // older kernels) the stream can drop, and returning stdout as success would // let a backup/restore whose OUTCOME IS UNKNOWN be recorded as a good run. // Bounded-retry the wait; if it still can't be determined, fall back to // inspecting the container's final State, and if that's also unavailable // leave exitCode undefined — the guard below fails closed on undefined. // HELPERS_WAIT_MODE (default 'poll'): the exit signal is read by POLLING short // inspect calls - nothing stays open for a proxy to cut. The alternative, a single // streaming `POST /wait` held open with no bytes for the whole run, gets severed // mid-run by a socket-proxy / reverse-proxy / tcp DOCKER_HOST with a short idle // timeout, leaving the exit code unreadable even though the helper is fine (#1344). // Poll is the safe default; set HELPERS_WAIT_MODE=wait to force the streaming /wait. // Applies to every helper this function runs — backup/restore AND the image scanner. let exitCode: number | undefined; if (process.env.HELPERS_WAIT_MODE !== 'wait') { // Bound by the caller's timeout (backup passes a long 'data' tier, the // scanner passes 600_000). Fall back to 1h so poll mode never hangs. // One line so the log confirms which exit-signal mode is active (#1344). console.log(`[runContainerWithStreaming] Awaiting exit of ${options.name ?? containerId.slice(0, 12)} via POLL mode`); const deadline = Date.now() + (options.timeout && options.timeout > 0 ? options.timeout : 3_600_000); while (exitCode === undefined && Date.now() < deadline) { try { const insp = await dockerFetch(`/containers/${containerId}/json`, {}, options.envId); if (insp.ok) { const data = await insp.json() as { State?: { Status?: string; Running?: boolean; Dead?: boolean; ExitCode?: number } }; const st = data.State; if (st && st.Running === false && st.Status === 'exited' && typeof st.ExitCode === 'number') { exitCode = st.ExitCode; console.log(`[runContainerWithStreaming] Container exited with code: ${exitCode} (poll mode)`); break; } if (st?.Dead || st?.Status === 'removing') break; // gone -> fail-closed diagnostics below } else if (insp.status === 404) { console.warn(`[runContainerWithStreaming] Poll: ${options.name ?? containerId.slice(0, 12)} not found (404) — container removed`); break; } } catch (err) { // A dropped short inspect is cheap to retry — keep polling to the deadline // rather than bailing (unlike the streaming /wait path). console.warn(`[runContainerWithStreaming] Poll inspect failed for ${options.name ?? containerId.slice(0, 12)}: ${(err as Error).message}`); } if (exitCode === undefined) await new Promise((r) => setTimeout(r, 1500)); } } else { for (let attempt = 0; attempt < 3 && exitCode === undefined; attempt++) { try { const waitResult = await dockerFetch(`/containers/${containerId}/wait`, { method: 'POST', streaming: true }, options.envId); if (!waitResult.ok) await throwDockerError(waitResult); const waitData = await waitResult.json() as { StatusCode?: number }; exitCode = waitData.StatusCode; console.log(`[runContainerWithStreaming] Container exited with code: ${exitCode}`); } catch (err) { // DIAGNOSTIC (issue #1344): include the attempt count so a user whose // helper's exit can't be read can tell us whether /wait failed once // (transient) or all 3 times (the container vanished / daemon churn). console.warn(`[runContainerWithStreaming] Wait attempt ${attempt + 1}/3 failed for ${options.name ?? containerId.slice(0, 12)}: ${(err as Error).message}`); } } } if (exitCode === undefined) { // Fallback: inspect the container. Only trust a definitively-exited // state; a still-running/restarting/unknown state stays undefined. let containerStartedAt: string | undefined; try { const insp = await dockerFetch(`/containers/${containerId}/json`, {}, options.envId); if (insp.ok) { const data = await insp.json() as { State?: { Status?: string; Running?: boolean; Restarting?: boolean; Dead?: boolean; Paused?: boolean; ExitCode?: number; OOMKilled?: boolean; Error?: string; StartedAt?: string; FinishedAt?: string; Pid?: number } }; const st = data.State; containerStartedAt = st?.StartedAt; // DIAGNOSTIC (issue #1344): /wait gave no code and the fallback is // about to leave exitCode undefined -> the user sees "could not // determine container exit status". Log the FULL final State so a // user hitting this can tell us WHY (OOMKilled, Dead, removed by an // external reaper, still Running = /wait raced, or a daemon Error). console.warn( `[runContainerWithStreaming] EXIT-CODE INDETERMINATE for ${options.name ?? containerId.slice(0, 12)} ` + `State=${JSON.stringify({ Status: st?.Status, Running: st?.Running, Restarting: st?.Restarting, Dead: st?.Dead, Paused: st?.Paused, ExitCode: st?.ExitCode, OOMKilled: st?.OOMKilled, Error: st?.Error, StartedAt: st?.StartedAt, FinishedAt: st?.FinishedAt, Pid: st?.Pid, })}` ); if (st && st.Running === false && st.Status === 'exited' && typeof st.ExitCode === 'number') { exitCode = st.ExitCode; console.log(`[runContainerWithStreaming] Resolved exit code via inspect: ${exitCode}`); } } else { console.warn(`[runContainerWithStreaming] EXIT-CODE INDETERMINATE for ${options.name ?? containerId.slice(0, 12)}: inspect returned HTTP ${insp.status} (container may already be gone/removed)`); } } catch (err) { console.warn(`[runContainerWithStreaming] Inspect fallback failed: ${(err as Error).message}`); } // Still indeterminate -> pull docker events so the log shows WHO/WHAT // acted on the container (die/kill/destroy + actor). Missing piece when // something external removes the helper mid-run (#1344). if (exitCode === undefined) { await logRecentContainerEvents(containerId, options.name ?? containerId.slice(0, 12), containerStartedAt, options.envId); } } // Container exited - abort stderr stream (it may be hanging on some Docker hosts) abortController.abort(); // Give brief moment for any final stderr data to flush await Promise.race([stderrPromise, new Promise(r => setTimeout(r, 1000))]); // Container has exited. Now fetch stdout reliably (no race condition). const stdout = await fetchContainerStdout(containerId, config, options.envId); // Fail closed on a non-zero OR indeterminate exit. Callers (backup/restore // helpers) explicitly assume a non-throwing return means a clean run, so an // unknown outcome must be an error, not a silent success. if (exitCode === undefined || exitCode !== 0) { let stderrText = ''; try { const stderrResponse = await dockerFetch( `/containers/${containerId}/logs?stdout=false&stderr=true&follow=false`, {}, options.envId ); const stderrBuffer = Buffer.from(await stderrResponse.arrayBuffer()); const stderrOutput = demuxDockerStream(stderrBuffer, { separateStreams: true }); stderrText = typeof stderrOutput === 'string' ? stderrOutput : stderrOutput.stderr; } catch { // Ignore stderr fetch errors } // Include stdout in the detail too — restic often writes its error // summary there — so callers' error matching (e.g. repo-not-init) works. const detail = (stderrText || stdout || 'no output').substring(0, 1000); const code = exitCode === undefined ? 'unknown (could not determine container exit status)' : String(exitCode); throw new Error(`Container exited with code ${code}: ${detail}`); } return stdout; }; const effectiveTimeout = options.timeout ?? 0; if (effectiveTimeout > 0) { return await Promise.race([ doWork(), new Promise((_, reject) => setTimeout(() => reject(new Error( `Container execution timed out after ${Math.round(effectiveTimeout / 1000)}s` )), effectiveTimeout) ) ]); } else { return await doWork(); } } finally { // Always cleanup container try { await drainResponse(await dockerFetch(`/containers/${containerId}?force=true`, { method: 'DELETE' }, options.envId)); } catch { // Ignore cleanup errors } } } // Stream only stderr for real-time progress (local/standard mode) async function streamLocalStderr( containerId: string, envId: number | null | undefined, onStderr?: (data: string) => void, signal?: AbortSignal, // Opt-in live STDOUT streaming. When provided, we follow stdout too and route // frames by their Docker stream type (processStreamFrames already demuxes 1=stdout // / 2=stderr). Callers that don't pass it (terminal/exec/logs) get the exact prior // behaviour: stderr-only follow, stdout untouched. Used by the backup helper so // restic's --json progress (which it writes to stdout) shows LIVE instead of // arriving buffered after the container exits. onStdout?: (data: string) => void ): Promise { const wantStdout = onStdout ? 'true' : 'false'; const response = await dockerFetch( `/containers/${containerId}/logs?stdout=${wantStdout}&stderr=true&follow=true`, { streaming: true }, envId ); if (!response.ok) await throwDockerError(response); const reader = response.body?.getReader(); if (!reader) return; // Cancel reader when abort signal fires (container exited) signal?.addEventListener('abort', () => reader.cancel(), { once: true }); try { let buffer: Buffer = Buffer.alloc(0); while (true) { const { done, value } = await reader.read(); if (done) break; buffer = Buffer.concat([buffer, Buffer.from(value)]); const result = processStreamFrames(buffer, onStdout, onStderr); buffer = result.remaining; } } catch { // Reader was cancelled via abort signal - expected } } // Stream only stderr for real-time progress (edge mode) async function streamEdgeStderr( environmentId: number, containerId: string, onStderr?: (data: string) => void, signal?: AbortSignal ): Promise { return new Promise((resolve, reject) => { let buffer: Buffer = Buffer.alloc(0); let resolved = false; const finish = () => { if (!resolved) { resolved = true; resolve(); } }; // Resolve when abort signal fires (container exited) signal?.addEventListener('abort', finish, { once: true }); sendEdgeStreamRequest( environmentId, 'GET', `/containers/${containerId}/logs?stdout=false&stderr=true&follow=true`, { onData: (data: string) => { if (resolved) return; try { const decoded = Buffer.from(data, 'base64'); buffer = Buffer.concat([buffer, decoded]); const result = processStreamFrames(buffer, undefined, onStderr); buffer = result.remaining; } catch { // Ignore decode errors } }, onEnd: () => finish(), onError: (error: string) => { // Container exited = success if (error.includes('container') && (error.includes('exited') || error.includes('not running'))) { finish(); } else if (!resolved) { resolved = true; reject(new Error(error)); } } } ); }); } // Extract stdout from a buffer, with raw fallback if frame parsing returns nothing. // Mirrors demuxDockerStream's fallback (line ~202-205) for non-multiplexed Docker output. function extractStdoutFromBuffer(buffer: Buffer): string { const result = processStreamFrames(buffer, undefined, undefined); if (buffer.length > 100000) { console.log( `[extractStdoutFromBuffer] Raw buffer: ${buffer.length} bytes, stdout: ${result.stdout.length} chars, ` + `remaining: ${result.remaining.length} bytes` ); } if (result.stdout.length === 0 && buffer.length > 0) { console.warn( `[extractStdoutFromBuffer] Frame parsing empty but buffer has ${buffer.length} bytes. ` + `First 16 bytes: [${Array.from(buffer.slice(0, 16)).join(', ')}]. Falling back to raw.` ); return buffer.toString('utf-8').replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, ''); } // If there's remaining data after frame parsing, append it as raw text if (result.remaining.length > 0) { console.warn( `[extractStdoutFromBuffer] ${result.remaining.length} bytes remaining after frame parsing, appending as raw` ); const rawTail = result.remaining.toString('utf-8').replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, ''); return result.stdout + rawTail; } return result.stdout; } // Fetch stdout after container has exited (reliable, no race) async function fetchContainerStdout( containerId: string, config: Awaited>, envId: number | null | undefined ): Promise { if (config.connectionType === 'hawser-edge' && config.environmentId) { const response = await sendEdgeRequest( config.environmentId, 'GET', `/containers/${containerId}/logs?stdout=true&stderr=false&follow=false` ); if (!response.body) return ''; const bodyData = typeof response.body === 'string' ? Buffer.from(response.body, 'base64') : Buffer.from(response.body); return extractStdoutFromBuffer(bodyData); } // Local/standard mode - read via streaming to handle large Docker log responses const response = await dockerFetch( `/containers/${containerId}/logs?stdout=true&stderr=false&follow=false`, { streaming: true }, envId ); if (!response.ok) await throwDockerError(response); const reader = response.body?.getReader(); if (!reader) { const buffer = Buffer.from(await response.arrayBuffer()); return extractStdoutFromBuffer(buffer); } const chunks: Uint8Array[] = []; let totalSize = 0; while (true) { const { done, value } = await reader.read(); if (done) break; if (value) { chunks.push(value); totalSize += value.length; } } const buffer = Buffer.concat(chunks, totalSize); return extractStdoutFromBuffer(buffer); } // Push image to registry export async function pushImage( imageTag: string, authConfig: { username?: string; password?: string; serveraddress: string }, onProgress?: (data: any) => void, envId?: number | null ): Promise { // Parse tag to get registry info const [repo, tag = 'latest'] = imageTag.split(':'); 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( pushUrl, { method: 'POST', streaming: true, headers: { 'X-Registry-Auth': authHeader } }, 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}`); } // Stream the response for progress updates const reader = response.body?.getReader(); if (!reader) return; const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) { if (line.trim()) { try { const data = JSON.parse(line); if (data.error) { console.error(`[Push] stream error: ${line}`); throw new Error(data.error); } if (onProgress) onProgress(data); } catch (e: any) { if (e.message && !e.message.includes('JSON')) { throw e; } } } } } } // Container filesystem operations export interface FileEntry { name: string; type: 'file' | 'directory' | 'symlink' | 'other'; size: number; permissions: string; owner: string; group: string; modified: string; linkTarget?: string; readonly?: boolean; } /** * Parse ls -la output into FileEntry array * Handles multiple formats: * - GNU ls with --time-style=long-iso: drwxr-xr-x 2 root root 4096 2024-12-08 10:30 dirname * - Standard GNU ls: drwxr-xr-x 2 root root 4096 Dec 8 10:30 dirname * - Busybox ls: drwxr-xr-x 2 root root 4096 Dec 8 10:30 dirname */ function parseLsOutput(output: string): FileEntry[] { const lines = output.trim().split('\n'); const entries: FileEntry[] = []; const currentYear = new Date().getFullYear(); // Month name to number mapping const monthMap: Record = { Jan: '01', Feb: '02', Mar: '03', Apr: '04', May: '05', Jun: '06', Jul: '07', Aug: '08', Sep: '09', Oct: '10', Nov: '11', Dec: '12' }; for (const line of lines) { // Skip total line, empty lines, and error messages if (!line || line.startsWith('total ') || line.includes('cannot access') || line.includes('Permission denied')) continue; let typeChar: string; let perms: string; let owner: string; let group: string; let sizeStr: string; let date: string; let time: string; let nameAndLink: string; // Try ISO format first (GNU ls with --time-style=long-iso) // Format: drwxr-xr-x 2 root root 4096 2024-12-08 10:30 dirname // With ACL: drwxr-xr-x+ 2 root root 4096 2024-12-08 10:30 dirname // With extended attrs: drwxr-xr-x@ 2 root root 4096 2024-12-08 10:30 dirname const isoMatch = line.match( /^([dlcbps-])([rwxsStT-]{9})[+@.]?\s+\d+\s+(\S+)\s+(\S+)\s+(\d+)\s+(\d{2,4}-\d{2}(?:-\d{2})?)\s+(\d{2}:\d{2})\s+(.+)$/ ); if (isoMatch) { [, typeChar, perms, owner, group, sizeStr, date, time, nameAndLink] = isoMatch; // Normalize date to YYYY-MM-DD format if (date.length <= 5) { // Format: MM-DD (no year) date = `${currentYear}-${date}`; } else if (!date.includes('-', 4)) { // Format: YYYY-MM (no day) date = `${date}-01`; } } else { // Try standard format (GNU/busybox without --time-style) // Format: drwxr-xr-x 2 root root 4096 Dec 8 10:30 dirname // Or: drwxr-xr-x 2 root root 4096 Dec 8 10:30 dirname // Or with year: drwxr-xr-x 2 root root 4096 Dec 8 2023 dirname // With ACL/attrs: drwxr-xr-x+ or drwxr-xr-x@ or drwxr-xr-x. const stdMatch = line.match( /^([dlcbps-])([rwxsStT-]{9})[+@.]?\s+\d+\s+(\S+)\s+(\S+)\s+(\d+)\s+(\w{3})\s+(\d{1,2})\s+(\d{1,2}:\d{2}|\d{4})\s+(.+)$/ ); if (!stdMatch) { // Try device file format (block/char devices have major,minor instead of size) // Format: crw-rw-rw- 1 root root 1, 3 Dec 8 10:30 null const deviceMatch = line.match( /^([cb])([rwxsStT-]{9})[+@.]?\s+\d+\s+(\S+)\s+(\S+)\s+(\d+),\s*(\d+)\s+(\w{3})\s+(\d{1,2})\s+(\d{1,2}:\d{2}|\d{4})\s+(.+)$/ ); if (deviceMatch) { let monthStr: string; let dayStr: string; let timeOrYear: string; [, typeChar, perms, owner, group, , , monthStr, dayStr, timeOrYear, nameAndLink] = deviceMatch; sizeStr = '0'; // Device files don't have a traditional size const month = monthMap[monthStr] || '01'; const day = dayStr.padStart(2, '0'); if (timeOrYear.includes(':')) { time = timeOrYear; date = `${currentYear}-${month}-${day}`; } else { time = '00:00'; date = `${timeOrYear}-${month}-${day}`; } } else { continue; } } else { let monthStr: string; let dayStr: string; let timeOrYear: string; [, typeChar, perms, owner, group, sizeStr, monthStr, dayStr, timeOrYear, nameAndLink] = stdMatch; const month = monthMap[monthStr] || '01'; const day = dayStr.padStart(2, '0'); // timeOrYear is either "HH:MM" or "YYYY" if (timeOrYear.includes(':')) { time = timeOrYear; date = `${currentYear}-${month}-${day}`; } else { time = '00:00'; date = `${timeOrYear}-${month}-${day}`; } } } let type: FileEntry['type']; switch (typeChar) { case 'd': type = 'directory'; break; case 'l': type = 'symlink'; break; case '-': type = 'file'; break; default: type = 'other'; } let name = nameAndLink; let linkTarget: string | undefined; // Handle symlinks: "name -> target" if (type === 'symlink' && nameAndLink.includes(' -> ')) { const parts = nameAndLink.split(' -> '); name = parts[0]; linkTarget = parts.slice(1).join(' -> '); } // Skip . and .. entries if (name === '.' || name === '..') continue; // Check if file is read-only (owner doesn't have write permission) // perms format: rwxrwxrwx - index 1 is owner write const isReadonly = perms.charAt(1) !== 'w'; entries.push({ name, type, size: parseInt(sizeStr, 10), permissions: perms, owner, group, modified: `${date}T${time}:00`, linkTarget, readonly: isReadonly }); } return entries; } /** * List files in a container directory * Tries multiple ls command variants for compatibility with different containers. */ export async function listContainerDirectory( containerId: string, path: string, envId?: number | null, useSimpleLs?: boolean ): Promise<{ path: string; entries: FileEntry[] }> { // Sanitize path to prevent command injection const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, ''); // Commands to try in order of preference (includes /usr/sbin/ls for Wolfi/busybox images) const commands = useSimpleLs ? [ ['ls', '-la', safePath], ['/bin/ls', '-la', safePath], ['/usr/bin/ls', '-la', safePath], ['/usr/sbin/ls', '-la', safePath], ] : [ ['ls', '-la', '--time-style=long-iso', safePath], ['ls', '-la', safePath], ['/bin/ls', '-la', safePath], ['/usr/bin/ls', '-la', safePath], ['/usr/sbin/ls', '-la', safePath], ]; let lastError: Error | null = null; for (const cmd of commands) { try { const output = await execInContainer(containerId, cmd, envId); const entries = parseLsOutput(output); return { path: safePath, entries }; } catch (err: any) { lastError = err; continue; } } throw lastError || new Error('Failed to list directory: no working ls command found'); } /** * Get file/directory archive from container (for download) * Returns the raw Docker API response for streaming */ export async function getContainerArchive( containerId: string, path: string, envId?: number | null ): Promise { // Sanitize path const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, ''); const response = await dockerFetch( `/containers/${containerId}/archive?path=${encodeURIComponent(safePath)}`, { streaming: true }, envId ); if (!response.ok) { const error = await response.text(); throw new Error(`Failed to get archive: ${error}`); } return response; } /** * Upload files to container (tar archive) */ export async function putContainerArchive( containerId: string, path: string, tarData: ArrayBuffer | Uint8Array, envId?: number | null ): Promise { // Sanitize path const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, ''); const response = await dockerFetch( `/containers/${containerId}/archive?path=${encodeURIComponent(safePath)}`, { method: 'PUT', headers: { 'Content-Type': 'application/x-tar' }, body: tarData as BodyInit }, envId ); if (!response.ok) { const error = await response.text(); throw new Error(`Failed to upload archive: ${error}`); } await drainResponse(response); } /** * Get stat info for a file/directory in container */ export async function statContainerPath( containerId: string, path: string, envId?: number | null ): Promise<{ name: string; size: number; mode: number; mtime: string; linkTarget?: string; isDir: boolean }> { // Sanitize path const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, ''); const response = await dockerFetch( `/containers/${containerId}/archive?path=${encodeURIComponent(safePath)}`, { method: 'HEAD' }, envId ); if (!response.ok) { await drainResponse(response); throw new Error(`Path not found: ${safePath}`); } // Docker returns stat info in X-Docker-Container-Path-Stat header as base64 JSON const statHeader = response.headers.get('X-Docker-Container-Path-Stat'); if (!statHeader) { throw new Error('No stat info returned'); } const statJson = Buffer.from(statHeader, 'base64').toString('utf-8'); const stat = JSON.parse(statJson); // Go's os.FileMode encodes the file type in the high bits. ModeDir = 1<<31. // Docker emits mode in that format, so the directory bit lives at 0x80000000. return { ...stat, isDir: (stat.mode & 0x80000000) !== 0 }; } /** * Read file content from container * Uses cat command via exec to read file contents */ export async function readContainerFile( containerId: string, path: string, envId?: number | null ): Promise { // Sanitize path to prevent command injection const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, ''); // Use cat to read file content const output = await execInContainer(containerId, ['cat', safePath], envId); return output; } /** * Write file content to container * Uses Docker archive API to write file */ export async function writeContainerFile( containerId: string, path: string, content: string, envId?: number | null ): Promise { // Sanitize path const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, ''); // Get directory and filename const parts = safePath.split('/'); const filename = parts.pop() || 'file'; const directory = parts.join('/') || '/'; // Create a minimal tar archive with the file // Tar format: 512-byte header + file content + padding to 512-byte boundary const contentBytes = new TextEncoder().encode(content); const fileSize = contentBytes.length; // Calculate total tar size (header + content + padding + two 512-byte end blocks) const paddedContentSize = Math.ceil(fileSize / 512) * 512; const tarSize = 512 + paddedContentSize + 1024; // header + padded content + end blocks const tarData = new Uint8Array(tarSize); // Write tar header (512 bytes) // File name (100 bytes) const filenameBytes = new TextEncoder().encode(filename); tarData.set(filenameBytes.slice(0, 100), 0); // File mode (8 bytes octal) - 0644 tarData.set(new TextEncoder().encode('0000644\0'), 100); // UID (8 bytes octal) - 0 tarData.set(new TextEncoder().encode('0000000\0'), 108); // GID (8 bytes octal) - 0 tarData.set(new TextEncoder().encode('0000000\0'), 116); // File size (12 bytes octal) const sizeOctal = fileSize.toString(8).padStart(11, '0') + '\0'; tarData.set(new TextEncoder().encode(sizeOctal), 124); // Mtime (12 bytes octal) - current time const mtime = Math.floor(Date.now() / 1000).toString(8).padStart(11, '0') + '\0'; tarData.set(new TextEncoder().encode(mtime), 136); // Checksum placeholder (8 bytes) - filled with spaces initially tarData.set(new TextEncoder().encode(' '), 148); // Type flag (1 byte) - '0' for regular file tarData[156] = 48; // ASCII '0' // Link name (100 bytes) - empty for regular files // Already zeros // USTAR magic (6 bytes) + version (2 bytes) tarData.set(new TextEncoder().encode('ustar\0'), 257); tarData.set(new TextEncoder().encode('00'), 263); // Owner name (32 bytes) - root tarData.set(new TextEncoder().encode('root'), 265); // Group name (32 bytes) - root tarData.set(new TextEncoder().encode('root'), 297); // Calculate and write checksum let checksum = 0; for (let i = 0; i < 512; i++) { checksum += tarData[i]; } const checksumOctal = checksum.toString(8).padStart(6, '0') + '\0 '; tarData.set(new TextEncoder().encode(checksumOctal), 148); // Write file content after header tarData.set(contentBytes, 512); // Upload to container await putContainerArchive(containerId, directory, tarData, envId); } /** * Create an empty file in container */ export async function createContainerFile( containerId: string, path: string, envId?: number | null ): Promise { // Sanitize path to prevent command injection const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, ''); // Use touch to create empty file await execInContainer(containerId, ['touch', safePath], envId); } /** * Create a directory in container */ export async function createContainerDirectory( containerId: string, path: string, envId?: number | null ): Promise { // Sanitize path to prevent command injection const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, ''); // Use mkdir -p to create directory (and parents if needed) await execInContainer(containerId, ['mkdir', '-p', safePath], envId); } /** * Delete a file or directory in container */ export async function deleteContainerPath( containerId: string, path: string, envId?: number | null ): Promise { // Sanitize path to prevent command injection const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, ''); // Safety check: don't allow deleting root or critical paths const dangerousPaths = ['/', '/bin', '/sbin', '/usr', '/lib', '/lib64', '/etc', '/var', '/root', '/home']; if (dangerousPaths.includes(safePath) || safePath === '') { throw new Error('Cannot delete critical system path'); } // Use rm -rf to delete file or directory await execInContainer(containerId, ['rm', '-rf', safePath], envId); } /** * Rename/move a file or directory in container */ export async function renameContainerPath( containerId: string, oldPath: string, newPath: string, envId?: number | null ): Promise { // Sanitize paths to prevent command injection const safeOldPath = oldPath.replace(/[;&|`$(){}[\]<>'"\\]/g, ''); const safeNewPath = newPath.replace(/[;&|`$(){}[\]<>'"\\]/g, ''); // Use mv to rename await execInContainer(containerId, ['mv', safeOldPath, safeNewPath], envId); } /** * Change permissions of a file or directory in container */ export async function chmodContainerPath( containerId: string, path: string, mode: string, recursive: boolean = false, envId?: number | null ): Promise { // Sanitize path to prevent command injection const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, ''); // Validate mode (should be octal like 755 or symbolic like u+x) if (!/^[0-7]{3,4}$/.test(mode) && !/^[ugoa]*[+-=][rwxXst]+$/.test(mode)) { throw new Error('Invalid chmod mode'); } // Build command const cmd = recursive ? ['chmod', '-R', mode, safePath] : ['chmod', mode, safePath]; await execInContainer(containerId, cmd, envId); } // Volume browsing and export helpers const VOLUME_HELPER_IMAGE = 'busybox:latest'; const VOLUME_MOUNT_PATH = '/volume'; const VOLUME_HELPER_TTL_SECONDS = 300; // 5 minutes TTL for helper containers // Cache for volume helper containers: key = `${volumeName}:${envId ?? 'local'}` -> containerId const volumeHelperCache = new Map(); /** * Get cache key for a volume helper container */ function getVolumeCacheKey(volumeName: string, envId?: number | null): string { return `${volumeName}:${envId ?? 'local'}`; } /** * Ensure the volume helper image (busybox) is available, pulling if necessary */ export async function ensureVolumeHelperImage(envId?: number | null): Promise { // Check if image exists const response = await dockerFetch(`/images/${encodeURIComponent(VOLUME_HELPER_IMAGE)}/json`, {}, envId); if (response.ok) { await drainResponse(response); return; // Image exists } // Image not found, pull it console.log(`Pulling ${VOLUME_HELPER_IMAGE} for volume browsing...`); const authHeaders = await buildRegistryAuthHeader(VOLUME_HELPER_IMAGE); const pullResponse = await dockerFetch( `/images/create?fromImage=${encodeURIComponent(VOLUME_HELPER_IMAGE)}`, { method: 'POST', headers: authHeaders }, envId ); if (!pullResponse.ok) { const error = await pullResponse.text(); throw new Error(`Failed to pull ${VOLUME_HELPER_IMAGE}: ${error}`); } // Wait for pull to complete by consuming the stream const reader = pullResponse.body?.getReader(); if (reader) { while (true) { const { done } = await reader.read(); if (done) break; } } console.log(`Successfully pulled ${VOLUME_HELPER_IMAGE}`); } /** * Check if a container exists and is running */ async function isContainerRunning(containerId: string, envId?: number | null): Promise { try { const response = await dockerFetch(`/containers/${containerId}/json`, {}, envId); if (!response.ok) { await response.text(); // Consume body to release socket return false; } const info = await response.json(); return info.State?.Running === true; } catch { return false; } } /** * Get or create a helper container for volume browsing. * Reuses existing containers from cache for better performance. * Returns the container ID. * @param readOnly - If true, mount volume read-only (default). If false, mount writable. */ export async function getOrCreateVolumeHelperContainer( volumeName: string, envId?: number | null, readOnly: boolean = true ): Promise { // Include readOnly in cache key since we need different containers for ro/rw const cacheKey = `${getVolumeCacheKey(volumeName, envId)}:${readOnly ? 'ro' : 'rw'}`; const now = Date.now(); // Check cache for existing container const cached = volumeHelperCache.get(cacheKey); if (cached && cached.expiresAt > now) { // Verify container is still running if (await isContainerRunning(cached.containerId, envId)) { // Refresh expiry time on access cached.expiresAt = now + VOLUME_HELPER_TTL_SECONDS * 1000; return cached.containerId; } // Container no longer running, remove from cache volumeHelperCache.delete(cacheKey); } // Ensure helper image is available (auto-pull if missing) await ensureVolumeHelperImage(envId); // Generate a unique container name based on volume name const safeVolumeName = volumeName.replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 50); const rwSuffix = readOnly ? 'ro' : 'rw'; const containerName = `dockhand-browse-${safeVolumeName}-${rwSuffix}-${Date.now().toString(36)}`; // Create a temporary container with the volume mounted const bindMount = readOnly ? `${volumeName}:${VOLUME_MOUNT_PATH}:ro` : `${volumeName}:${VOLUME_MOUNT_PATH}`; const containerConfig = { Image: VOLUME_HELPER_IMAGE, Cmd: ['sleep', 'infinity'], // Keep alive indefinitely (managed by cache TTL) HostConfig: { Binds: [bindMount], AutoRemove: false }, Labels: { 'dockhand.volume.helper': 'true', 'dockhand.volume.name': volumeName, 'dockhand.volume.readonly': String(readOnly) } }; const response = await dockerJsonRequest<{ Id: string }>( `/containers/create?name=${encodeURIComponent(containerName)}`, { method: 'POST', body: JSON.stringify(containerConfig) }, envId ); const containerId = response.Id; // Start the container await drainResponse(await dockerFetch(`/containers/${containerId}/start`, { method: 'POST' }, envId)); // Cache the container volumeHelperCache.set(cacheKey, { containerId, expiresAt: now + VOLUME_HELPER_TTL_SECONDS * 1000 }); return containerId; } /** * @deprecated Use getOrCreateVolumeHelperContainer instead * Create a temporary container with a volume mounted for browsing/export * Returns the container ID. Caller is responsible for removing the container. */ export async function createVolumeHelperContainer( volumeName: string, envId?: number | null ): Promise { return getOrCreateVolumeHelperContainer(volumeName, envId); } /** * Release a cached volume helper container when done browsing. * This removes the container from cache and stops/removes it from Docker. * Cleans up both ro and rw variants if they exist. */ export async function releaseVolumeHelperContainer( volumeName: string, envId?: number | null ): Promise { const baseCacheKey = getVolumeCacheKey(volumeName, envId); // Clean up both read-only and read-write variants for (const suffix of [':ro', ':rw']) { const cacheKey = baseCacheKey + suffix; const cached = volumeHelperCache.get(cacheKey); if (cached) { volumeHelperCache.delete(cacheKey); await removeVolumeHelperContainer(cached.containerId, envId).catch(err => { console.warn('Failed to cleanup volume helper container:', err); }); } } } /** * Cleanup expired volume helper containers. * Called periodically to remove containers that have exceeded their TTL. */ export async function cleanupExpiredVolumeHelpers(): Promise { const now = Date.now(); const expiredEntries: Array<{ key: string; containerId: string; envId?: number | null }> = []; for (const [key, cached] of volumeHelperCache.entries()) { if (cached.expiresAt <= now) { // Parse envId from key: "volumeName:envId" or "volumeName:local" const [, envIdStr] = key.split(':'); const envId = envIdStr === 'local' ? null : parseInt(envIdStr); expiredEntries.push({ key, containerId: cached.containerId, envId }); } } // Remove from cache and cleanup containers for (const { key, containerId, envId } of expiredEntries) { volumeHelperCache.delete(key); removeVolumeHelperContainer(containerId, envId ?? undefined).catch(err => { console.warn('Failed to cleanup expired volume helper container:', err); }); } if (expiredEntries.length > 0) { console.log(`Cleaned up ${expiredEntries.length} expired volume helper container(s)`); } } /** * Remove a volume helper container */ export async function removeVolumeHelperContainer( containerId: string, envId?: number | null ): Promise { try { // Stop the container first (force) await drainResponse(await dockerFetch(`/containers/${containerId}/stop?t=1`, { method: 'POST' }, envId)); } catch { // Ignore stop errors } // Remove the container await drainResponse(await dockerFetch(`/containers/${containerId}?force=true`, { method: 'DELETE' }, envId)); } /** * Cleanup all stale volume helper containers on a specific environment. * Finds containers with label dockhand.volume.helper=true and removes them. * Called on startup to clean up containers from previous process runs. */ async function cleanupStaleVolumeHelpersForEnv(envId?: number | null): Promise { try { // Query containers with our helper label const filters = JSON.stringify({ label: ['dockhand.volume.helper=true'] }); const response = await dockerFetch( `/containers/json?all=true&filters=${encodeURIComponent(filters)}`, {}, envId ); if (!response.ok) { await response.text(); // Consume body to release socket return 0; } const containers: Array<{ Id: string; Names: string[] }> = await response.json(); let removed = 0; for (const container of containers) { try { await removeVolumeHelperContainer(container.Id, envId); removed++; } catch (err) { console.warn(`Failed to remove stale helper container ${container.Names?.[0] || container.Id}:`, err); } } return removed; } catch (err: any) { // Don't spam logs for expected connection failures (offline envs, TLS mismatches, etc.) const msg = err?.message || String(err); const isExpected = /not connected|offline|unreachable|fetch failed|EPROTO|ECONNREFUSED|ECONNRESET|ETIMEDOUT|EHOSTUNREACH/i.test(msg); if (!isExpected) { console.warn(`Failed to query stale volume helpers for env ${envId}:`, msg); } return 0; } } /** * Cleanup stale volume helper containers across all environments. * Should be called on startup to clean up orphaned containers. * @param environments - Optional pre-fetched environments (avoids dynamic import in production) */ export async function cleanupStaleVolumeHelpers(environments: Array<{ id: number }>): Promise { if (!environments || environments.length === 0) return; let totalRemoved = 0; for (const env of environments) { totalRemoved += await cleanupStaleVolumeHelpersForEnv(env.id); } if (totalRemoved > 0) { console.log(`[Volume Helper] Removed ${totalRemoved} stale container(s)`); } } /** * Reap orphaned backup/restore helper containers on a specific environment left * by a crashed process. Only removes helpers THIS installation owns (name prefix + * matching dockhand.instance label); a missing/foreign label is skipped so a * co-located Dockhand's live helper on a shared daemon is never touched. */ async function cleanupStaleBackupHelpersForEnv(myInstance: string, envId?: number | null): Promise { try { const containers = await listContainers(true, envId ?? undefined); let removed = 0; for (const c of containers) { if (!isOwnedBackupHelper(c.name, c.labels, myInstance)) continue; try { await removeVolumeHelperContainer(c.id, envId ?? undefined); removed++; } catch (err) { console.warn(`Failed to remove orphan backup helper ${c.name || c.id}:`, err); } } return removed; } catch (err: any) { // Don't spam logs for expected connection failures (offline envs, TLS mismatches, etc.) const msg = err?.message || String(err); const isExpected = /not connected|offline|unreachable|fetch failed|EPROTO|ECONNREFUSED|ECONNRESET|ETIMEDOUT|EHOSTUNREACH/i.test(msg); if (!isExpected) { console.warn(`Failed to query orphan backup helpers for env ${envId}:`, msg); } return 0; } } /** * Reap orphaned backup/restore helper containers across all environments (plus * the local daemon when there are none). Call on startup, BEFORE the backup engine * reconciles, so no live helper from a crashed run is still mid-operation. Returns * the number removed. Only reaps helpers this installation owns (fail-closed on the * instance label). */ export async function cleanupStaleBackupHelpers(environments: Array<{ id: number }>): Promise { const myInstance = await getInstanceId(); const envIds: (number | undefined)[] = environments.length ? environments.map((e) => e.id) : [undefined]; let totalRemoved = 0; for (const envId of envIds) { totalRemoved += await cleanupStaleBackupHelpersForEnv(myInstance, envId); } if (totalRemoved > 0) { console.log(`[Backups] Reaped ${totalRemoved} orphan helper container(s) from a previous run`); } return totalRemoved; } /** * List directory contents in a volume * Uses cached helper containers for better performance. */ export async function listVolumeDirectory( volumeName: string, path: string, envId?: number | null, readOnly: boolean = true ): Promise<{ path: string; entries: FileEntry[]; containerId: string }> { const containerId = await getOrCreateVolumeHelperContainer(volumeName, envId, readOnly); // Sanitize path const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, ''); const fullPath = `${VOLUME_MOUNT_PATH}${safePath.startsWith('/') ? safePath : '/' + safePath}`; // Use simple ls since busybox doesn't support --time-style const output = await execInContainer(containerId, ['ls', '-la', fullPath], envId); const entries = parseLsOutput(output); return { path: safePath || '/', entries, containerId }; // Note: Container is kept alive for reuse. It will be cleaned up // when the cache TTL expires or when the volume browser modal closes. } /** * Get archive of volume contents for download * Uses cached helper containers for better performance. */ export async function getVolumeArchive( volumeName: string, path: string, envId?: number | null, readOnly: boolean = true ): Promise<{ response: Response; containerId: string }> { const containerId = await getOrCreateVolumeHelperContainer(volumeName, envId, readOnly); // Sanitize path const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, ''); const fullPath = `${VOLUME_MOUNT_PATH}${safePath.startsWith('/') ? safePath : '/' + safePath}`; const response = await dockerFetch( `/containers/${containerId}/archive?path=${encodeURIComponent(fullPath)}`, { streaming: true }, envId ); if (!response.ok) { const error = await response.text(); throw new Error(`Failed to get archive: ${error}`); } return { response, containerId }; // Note: Container is kept alive for reuse. Cache TTL will handle cleanup. } /** * Stat a path inside a volume via the helper container. * Returns the same shape as statContainerPath (#1180 raw download needs isDir). */ export async function statVolumePath( volumeName: string, path: string, envId?: number | null, readOnly: boolean = true ): Promise<{ name: string; size: number; mode: number; mtime: string; linkTarget?: string; isDir: boolean }> { const containerId = await getOrCreateVolumeHelperContainer(volumeName, envId, readOnly); const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, ''); const fullPath = `${VOLUME_MOUNT_PATH}${safePath.startsWith('/') ? safePath : '/' + safePath}`; return statContainerPath(containerId, fullPath, envId); } /** * Read file content from volume * Uses cached helper containers for better performance. */ export async function readVolumeFile( volumeName: string, path: string, envId?: number | null, readOnly: boolean = true ): Promise { const containerId = await getOrCreateVolumeHelperContainer(volumeName, envId, readOnly); // Sanitize path const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, ''); const fullPath = `${VOLUME_MOUNT_PATH}${safePath.startsWith('/') ? safePath : '/' + safePath}`; // Use cat to read file content const output = await execInContainer(containerId, ['cat', fullPath], envId); return output; // Note: Container is kept alive for reuse. Cache TTL will handle cleanup. }