diff --git a/package.json b/package.json index 06dd725..15dc7dc 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dockhand", "private": true, - "version": "1.0.15", + "version": "1.0.16", "type": "module", "scripts": { "dev": "bunx --bun vite dev", diff --git a/src/lib/data/changelog.json b/src/lib/data/changelog.json index 3b4689c..db47698 100644 --- a/src/lib/data/changelog.json +++ b/src/lib/data/changelog.json @@ -1,4 +1,16 @@ [ + { + "version": "1.0.16", + "date": "2026-02-09", + "changes": [ + { "type": "feature", "text": "Support Docker Compose override files when deploying stacks" }, + { "type": "fix", "text": "Fix Hawser stack deploy failing when compose file not present on remote host" }, + { "type": "fix", "text": "Fix Hawser Standard TLS test connection sending HTTP to HTTPS server" }, + { "type": "fix", "text": "Fix .env variables not applied on save & redeploy" }, + { "type": "fix", "text": "Fix single Hawser node failure cascading offline state to all environments" } + ], + "imageTag": "fnsys/dockhand:v1.0.16" + }, { "version": "1.0.15", "date": "2026-02-08", diff --git a/src/lib/server/docker.ts b/src/lib/server/docker.ts index b2261a1..9440ed6 100644 --- a/src/lib/server/docker.ts +++ b/src/lib/server/docker.ts @@ -549,16 +549,26 @@ export async function dockerFetch( if (config.type === 'https') { const tlsOptions: Record = {}; - // DISABLE TLS SESSION CACHING: Bun reuses TLS sessions across different hosts, - // which causes client certificate mismatches in mTLS scenarios. By setting - // sessionTimeout to 0, we force a fresh TLS handshake for every connection. - tlsOptions.sessionTimeout = 0; + // Detect if mutual TLS (client certificate authentication) is in use + const isMtls = !!(config.cert && config.key); - // Set explicit servername for SNI - helps isolate TLS contexts per host + if (isMtls) { + // mTLS: Disable session caching to prevent Bun from reusing a TLS session + // with wrong client certificates (pool key doesn't include certs) + tlsOptions.sessionTimeout = 0; + } else { + // Non-mTLS HTTPS (CA-only or skip-verify): Allow short-lived session reuse. + // Without this, every fetch allocates a new native TLS context in BoringSSL. + // Native memory (mmap) is never returned to the OS, causing RSS to grow + // continuously in long-running subprocesses (metrics, events). + // 30s allows sessions to be reused within one metrics cycle, then expire. + tlsOptions.sessionTimeout = 30; + } + + // Set explicit servername for SNI - isolates TLS contexts per host tlsOptions.servername = config.host; // Load CA certificate (just this environment's CA, not composite) - // The sessionTimeout=0 should prevent session reuse across hosts if (config.ca) { tlsOptions.ca = [config.ca]; } @@ -581,10 +591,14 @@ export async function dockerFetch( if (Object.keys(tlsOptions).length > 0) { // @ts-ignore - Bun supports tls options with string certs finalOptions.tls = tlsOptions; - // Force new connection for each request to prevent Bun from reusing - // a TLS session with wrong client certificates (pool key doesn't include certs) - // @ts-ignore - Bun supports keepalive option - finalOptions.keepalive = false; + if (isMtls) { + // mTLS: Force new connection for each request to prevent Bun from + // reusing a TLS session with wrong client certificates + // @ts-ignore - Bun supports keepalive option + finalOptions.keepalive = false; + } + // Non-mTLS: Use Bun's default keepalive (connection reuse) to avoid + // allocating a new native TLS context per request } // Optional verbose TLS debugging diff --git a/src/lib/server/stacks.ts b/src/lib/server/stacks.ts index 81a030c..4504a81 100644 --- a/src/lib/server/stacks.ts +++ b/src/lib/server/stacks.ts @@ -765,6 +765,26 @@ interface ComposeCommandOptions { composeFileName?: string; } +/** + * Find a Docker Compose override file alongside the main compose file. + * Docker Compose auto-discovers these when no -f flag is used, but when -f is required + * we need to explicitly include the override file. + */ +function findComposeOverrideFile(stackDir: string, composeFileName: string): string | null { + const overrideMap: Record = { + 'compose.yaml': ['compose.override.yaml', 'compose.override.yml'], + 'compose.yml': ['compose.override.yaml', 'compose.override.yml'], + 'docker-compose.yaml': ['docker-compose.override.yaml', 'docker-compose.override.yml'], + 'docker-compose.yml': ['docker-compose.override.yaml', 'docker-compose.override.yml'], + }; + const candidates = overrideMap[composeFileName] || []; + for (const name of candidates) { + const fullPath = join(stackDir, name); + if (existsSync(fullPath)) return fullPath; + } + return null; +} + /** * Execute a docker compose command locally via Bun.spawn. * @@ -910,7 +930,38 @@ async function executeLocalCompose( // Build command based on operation // If we have modified compose content (host path translation), use stdin instead of file const useStdin = finalComposeContent !== composeContent; - const args = ['docker', 'compose', '-p', stackName, '-f', useStdin ? '-' : composeFile]; + const args = ['docker', 'compose', '-p', stackName]; + + // Temp file for path-translated override content (cleaned up in finally block) + let tempOverridePath: string | undefined; + + if (useStdin) { + // Host path translation: must pipe modified content via stdin + args.push('-f', '-'); + // Also include override file if it exists (needs path translation too) + const overrideFile = findComposeOverrideFile(stackDir, basename(composeFile)); + if (overrideFile) { + let overrideContent = await Bun.file(overrideFile).text(); + if (getHostDataDir()) { + const rewrite = rewriteComposeVolumePaths(overrideContent, stackDir); + if (rewrite.modified) overrideContent = rewrite.content; + } + tempOverridePath = join(stackDir, '.compose.override.translated.yaml'); + await Bun.write(tempOverridePath, overrideContent); + args.push('-f', tempOverridePath); + console.log(`${logPrefix} Including override file (path-translated): ${basename(overrideFile)}`); + } + } else if (customComposePath) { + // Custom path (imported/adopted stacks): must use -f to point to non-standard location + args.push('-f', composeFile); + const overrideFile = findComposeOverrideFile(stackDir, basename(composeFile)); + if (overrideFile) { + args.push('-f', overrideFile); + console.log(`${logPrefix} Including override file: ${basename(overrideFile)}`); + } + } + // else: internal stack without path translation - no -f needed. + // Docker Compose auto-discovers compose.yaml + compose.override.yaml from cwd. // Always auto-detect .env in compose directory (defaultEnvPath already defined above) if (existsSync(defaultEnvPath)) { @@ -1078,6 +1129,15 @@ async function executeLocalCompose( error: `Failed to run docker compose ${operation}: ${err.message}` }; } finally { + // Cleanup temp override file from host path translation + if (tempOverridePath) { + try { + unlinkSync(tempOverridePath); + } catch { + // Ignore cleanup errors + } + } + // Cleanup TLS temp directory (always runs, even on exception) if (tlsCertDir) { activeTlsDirs.delete(tlsCertDir); @@ -1293,6 +1353,24 @@ async function executeComposeCommand( console.warn(`[Stack:${stackName}] Failed to read .env file at ${envPath}:`, err); } } + + // Include compose override file if it exists alongside the compose file + let hawserStackFiles = stackFiles; + const composeDir = workingDir || (composePath ? dirname(composePath) : null); + const composeBaseName = composePath ? basename(composePath) : 'compose.yaml'; + if (composeDir) { + const overridePath = findComposeOverrideFile(composeDir, composeBaseName); + if (overridePath) { + try { + const overrideContent = await Bun.file(overridePath).text(); + hawserStackFiles = { ...(hawserStackFiles || {}), [basename(overridePath)]: overrideContent }; + console.log(`[Stack:${stackName}] Including override file for Hawser: ${basename(overridePath)}`); + } catch (err) { + console.warn(`[Stack:${stackName}] Failed to read override file at ${overridePath}:`, err); + } + } + } + return executeComposeViaHawser( operation, stackName, @@ -1302,7 +1380,7 @@ async function executeComposeCommand( secretVars, forceRecreate, removeVolumes, - stackFiles, + hawserStackFiles, serviceName, composeFileName ); @@ -2037,6 +2115,27 @@ export async function deployStack(options: DeployStackOptions): Promise { const fetchOpts: any = { headers: inspectHeaders }; if (config.type === 'https') { fetchOpts.tls = { - sessionTimeout: 0, // Disable TLS session caching for mTLS + sessionTimeout: 0, servername: config.host, - rejectUnauthorized: true + rejectUnauthorized: !config.skipVerify }; if (config.ca) fetchOpts.tls.ca = [config.ca]; if (config.cert) fetchOpts.tls.cert = [config.cert]; if (config.key) fetchOpts.tls.key = config.key; fetchOpts.keepalive = false; + if (process.env.DEBUG_TLS) fetchOpts.verbose = true; } inspectResponse = await fetch(inspectUrl, fetchOpts); } @@ -355,14 +358,15 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { }; if (config.type === 'https') { fetchOpts.tls = { - sessionTimeout: 0, // Disable TLS session caching for mTLS + sessionTimeout: 0, servername: config.host, - rejectUnauthorized: true + rejectUnauthorized: !config.skipVerify }; if (config.ca) fetchOpts.tls.ca = [config.ca]; if (config.cert) fetchOpts.tls.cert = [config.cert]; if (config.key) fetchOpts.tls.key = config.key; fetchOpts.keepalive = false; + if (process.env.DEBUG_TLS) fetchOpts.verbose = true; } response = await fetch(logsUrl, fetchOpts); } diff --git a/src/routes/api/dashboard/stats/stream/+server.ts b/src/routes/api/dashboard/stats/stream/+server.ts index 25389d5..ba6cab0 100644 --- a/src/routes/api/dashboard/stats/stream/+server.ts +++ b/src/routes/api/dashboard/stats/stream/+server.ts @@ -314,6 +314,11 @@ async function getEnvironmentStatsProgressive( }); return images; + }) + .catch(() => { + envStats.loading!.images = false; + onPartialUpdate({ id: env.id, loading: { ...envStats.loading! } }); + return []; }); const networksPromise = withTimeout(listNetworks(env.id).catch(() => []), 10000, []) @@ -328,6 +333,11 @@ async function getEnvironmentStatsProgressive( }); return networks; + }) + .catch(() => { + envStats.loading!.networks = false; + onPartialUpdate({ id: env.id, loading: { ...envStats.loading! } }); + return []; }); const stacksPromise = withTimeout(listComposeStacks(env.id).catch(() => []), 10000, []) @@ -345,6 +355,11 @@ async function getEnvironmentStatsProgressive( }); return stacks; + }) + .catch(() => { + envStats.loading!.stacks = false; + onPartialUpdate({ id: env.id, loading: { ...envStats.loading! } }); + return []; }); // PHASE 3: Disk usage (slow - includes volumes) - uses cache for better performance @@ -390,6 +405,12 @@ async function getEnvironmentStatsProgressive( }); return diskUsage; + }) + .catch(() => { + envStats.loading!.volumes = false; + envStats.loading!.diskUsage = false; + onPartialUpdate({ id: env.id, loading: { ...envStats.loading! } }); + return null; }); // PHASE 4: Top containers (slow - requires per-container stats) @@ -436,10 +457,14 @@ async function getEnvironmentStatsProgressive( }); return envStats.topContainers; + }).catch(() => { + envStats.loading!.topContainers = false; + onPartialUpdate({ id: env.id, loading: { ...envStats.loading! } }); + return []; }); // Wait for all to complete - await Promise.all([ + await Promise.allSettled([ containersPromise, imagesPromise, networksPromise, @@ -572,7 +597,7 @@ export const GET: RequestHandler = async ({ cookies }) => { }); // Wait for all to complete - await Promise.all(promises); + await Promise.allSettled(promises); // Send done event and close if (!controllerClosed) { diff --git a/src/routes/api/environments/[id]/test/+server.ts b/src/routes/api/environments/[id]/test/+server.ts index d4e8af7..cf97c58 100644 --- a/src/routes/api/environments/[id]/test/+server.ts +++ b/src/routes/api/environments/[id]/test/+server.ts @@ -1,7 +1,7 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { getEnvironment, updateEnvironment } from '$lib/server/db'; -import { getDockerInfo } from '$lib/server/docker'; +import { getDockerInfo, getHawserInfo } from '$lib/server/docker'; import { edgeConnections, isEdgeConnected } from '$lib/server/hawser'; export const POST: RequestHandler = async ({ params }) => { @@ -75,28 +75,15 @@ export const POST: RequestHandler = async ({ params }) => { // For Hawser Standard mode, fetch Hawser info (Edge mode handled above with early return) let hawserInfo = null; if (env.connectionType === 'hawser-standard') { - // Standard mode: fetch via HTTP try { - const protocol = env.useTls ? 'https' : 'http'; - const headers: Record = {}; - if (env.hawserToken) { - headers['X-Hawser-Token'] = env.hawserToken; - } - const hawserResp = await fetch(`${protocol}://${env.host}:${env.port || 2376}/_hawser/info`, { - headers, - signal: AbortSignal.timeout(5000) - }); - if (hawserResp.ok) { - hawserInfo = await hawserResp.json(); - // Save hawser info to database - if (hawserInfo?.hawserVersion) { - await updateEnvironment(id, { - hawserVersion: hawserInfo.hawserVersion, - hawserAgentId: hawserInfo.agentId, - hawserAgentName: hawserInfo.agentName, - hawserLastSeen: new Date().toISOString() - }); - } + hawserInfo = await getHawserInfo(id); + if (hawserInfo?.hawserVersion) { + await updateEnvironment(id, { + hawserVersion: hawserInfo.hawserVersion, + hawserAgentId: hawserInfo.agentId, + hawserAgentName: hawserInfo.agentName, + hawserLastSeen: new Date().toISOString() + }); } } catch { // Hawser info fetch failed, continue without it diff --git a/src/routes/api/environments/test/+server.ts b/src/routes/api/environments/test/+server.ts index 7222c8b..d5300ab 100644 --- a/src/routes/api/environments/test/+server.ts +++ b/src/routes/api/environments/test/+server.ts @@ -14,6 +14,39 @@ interface TestConnectionRequest { hawserToken?: string; } +function cleanPem(pem: string): string { + return pem + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .join('\n'); +} + +function buildTlsOptions(config: TestConnectionRequest): Record | undefined { + const protocol = config.protocol || 'http'; + if (protocol !== 'https') return undefined; + + const tls: Record = { + sessionTimeout: 0, + servername: config.host + }; + if (config.tlsSkipVerify) { + tls.rejectUnauthorized = false; + } else { + tls.rejectUnauthorized = true; + if (config.tlsCa) { + tls.ca = [cleanPem(config.tlsCa)]; + } + } + if (config.tlsCert) { + tls.cert = [cleanPem(config.tlsCert)]; + } + if (config.tlsKey) { + tls.key = cleanPem(config.tlsKey); + } + return tls; +} + /** * Test Docker connection with provided configuration (without saving to database) */ @@ -55,78 +88,23 @@ export const POST: RequestHandler = async ({ request }) => { 'Content-Type': 'application/json' }; - // Add Hawser token if present if (config.connectionType === 'hawser-standard' && config.hawserToken) { headers['X-Hawser-Token'] = config.hawserToken; } - // For HTTPS with custom CA, client certs, or skip verification, use subprocess to avoid Vite dev server TLS issues - if (protocol === 'https' && (config.tlsCa || config.tlsCert || config.tlsSkipVerify)) { - // Clean PEM content (remove extra whitespace) - const cleanPem = (pem: string) => pem - .split('\n') - .map((line) => line.trim()) - .filter((line) => line.length > 0) - .join('\n'); + const fetchOptions: any = { + headers, + signal: AbortSignal.timeout(10000), + keepalive: false + }; - // Pass config as base64-encoded JSON to avoid escaping issues - const tlsConfig = { - url: `https://${host}:${port}/info`, - headers, - tlsSkipVerify: config.tlsSkipVerify || false, - ca: config.tlsCa && !config.tlsSkipVerify ? cleanPem(config.tlsCa) : null, - cert: config.tlsCert ? cleanPem(config.tlsCert) : null, - key: config.tlsKey ? cleanPem(config.tlsKey) : null, - host - }; - const configBase64 = Buffer.from(JSON.stringify(tlsConfig)).toString('base64'); - - // Inline script with config embedded (bun -e doesn't pass argv correctly) - const scriptContent = ` -const config = JSON.parse(Buffer.from('${configBase64}', 'base64').toString()); -try { - const tls = { - sessionTimeout: 0, - servername: config.host, - rejectUnauthorized: !config.tlsSkipVerify - }; - if (config.ca) tls.ca = [config.ca]; - if (config.cert) tls.cert = [config.cert]; - if (config.key) tls.key = config.key; - const response = await fetch(config.url, { - headers: config.headers, - tls, - keepalive: false - }); - const body = await response.text(); - console.log(JSON.stringify({ status: response.status, body })); -} catch (e) { - console.log(JSON.stringify({ error: e.message })); -} -`; - const proc = Bun.spawn(['bun', '-e', scriptContent], { stdout: 'pipe', stderr: 'pipe' }); - const output = await new Response(proc.stdout).text(); - const stderr = await new Response(proc.stderr).text(); - - if (!output.trim()) { - throw new Error(stderr || 'Empty response from TLS test subprocess'); - } - const result = JSON.parse(output.trim()); - - if (result.error) { - throw new Error(result.error); - } - - response = new Response(result.body, { - status: result.status, - headers: { 'Content-Type': 'application/json' } - }); - } else { - response = await fetch(url, { - headers, - signal: AbortSignal.timeout(10000) - }); + const tls = buildTlsOptions(config); + if (tls) { + fetchOptions.tls = tls; + if (process.env.DEBUG_TLS) fetchOptions.verbose = true; } + + response = await fetch(url, fetchOptions); } if (!response.ok) { @@ -141,17 +119,25 @@ try { if (config.connectionType === 'hawser-standard' && config.host) { try { const protocol = config.protocol || 'http'; - const headers: Record = {}; + const hawserHeaders: Record = {}; if (config.hawserToken) { - headers['X-Hawser-Token'] = config.hawserToken; + hawserHeaders['X-Hawser-Token'] = config.hawserToken; } - const hawserResp = await fetch( - `${protocol}://${config.host}:${config.port || 2375}/_hawser/info`, - { - headers, - signal: AbortSignal.timeout(5000) - } - ); + const hawserUrl = `${protocol}://${config.host}:${config.port || 2375}/_hawser/info`; + + const fetchOptions: any = { + headers: hawserHeaders, + signal: AbortSignal.timeout(5000), + keepalive: false + }; + + const tls = buildTlsOptions(config); + if (tls) { + fetchOptions.tls = tls; + if (process.env.DEBUG_TLS) fetchOptions.verbose = true; + } + + const hawserResp = await fetch(hawserUrl, fetchOptions); if (hawserResp.ok) { hawserInfo = await hawserResp.json(); } diff --git a/src/routes/api/logs/merged/+server.ts b/src/routes/api/logs/merged/+server.ts index e3d0715..51492aa 100644 --- a/src/routes/api/logs/merged/+server.ts +++ b/src/routes/api/logs/merged/+server.ts @@ -36,6 +36,7 @@ interface DockerClientConfig { ca?: string; cert?: string; key?: string; + skipVerify?: boolean; hawserToken?: string; environmentId?: number; } @@ -62,6 +63,7 @@ async function getDockerConfig(envId?: number | null): Promise { if (config.hawserToken) inspectHeaders['X-Hawser-Token'] = config.hawserToken; // Build fetch options - only include tls for HTTPS - const fetchOptions: RequestInit & { tls?: unknown } = { + const fetchOptions: any = { headers: inspectHeaders, - signal: AbortSignal.timeout(30000) // 30 second timeout for inspect + signal: AbortSignal.timeout(30000) }; - if (config.type === 'https' && config.ca) { - // @ts-ignore - Bun TLS option - fetchOptions.tls = { ca: config.ca, cert: config.cert, key: config.key }; + if (config.type === 'https') { + fetchOptions.tls = { + sessionTimeout: 0, + servername: config.host, + rejectUnauthorized: !config.skipVerify + }; + if (config.ca) fetchOptions.tls.ca = [config.ca]; + if (config.cert) fetchOptions.tls.cert = [config.cert]; + if (config.key) fetchOptions.tls.key = config.key; + fetchOptions.keepalive = false; + if (process.env.DEBUG_TLS) fetchOptions.verbose = true; } inspectResponse = await fetch(inspectUrl, fetchOptions); @@ -470,13 +480,21 @@ export const GET: RequestHandler = async ({ url, cookies }) => { // For logs streaming, use the cleanup abort controller without a timeout // (the stream needs to stay open indefinitely) - const fetchOptions: RequestInit & { tls?: unknown } = { + const fetchOptions: any = { headers: logsHeaders, signal: abortController.signal }; - if (config.type === 'https' && config.ca) { - // @ts-ignore - Bun TLS option - fetchOptions.tls = { ca: config.ca, cert: config.cert, key: config.key }; + if (config.type === 'https') { + fetchOptions.tls = { + sessionTimeout: 0, + servername: config.host, + rejectUnauthorized: !config.skipVerify + }; + if (config.ca) fetchOptions.tls.ca = [config.ca]; + if (config.cert) fetchOptions.tls.cert = [config.cert]; + if (config.key) fetchOptions.tls.key = config.key; + fetchOptions.keepalive = false; + if (process.env.DEBUG_TLS) fetchOptions.verbose = true; } logsResponse = await fetch(logsUrl, fetchOptions); diff --git a/src/routes/stacks/StackModal.svelte b/src/routes/stacks/StackModal.svelte index f5b6dea..e2e10c9 100644 --- a/src/routes/stacks/StackModal.svelte +++ b/src/routes/stacks/StackModal.svelte @@ -284,7 +284,7 @@ if (!response.ok) { const data = await response.json(); - throw new Error(data.error || 'Failed to move files'); + throw new Error((typeof data.error === 'string' ? data.error : data.message) || 'Failed to move files'); } const result = await response.json(); @@ -766,7 +766,7 @@ services: } return; } - throw new Error(data.error || 'Failed to load compose file'); + throw new Error((typeof data.error === 'string' ? data.error : data.message) || 'Failed to load compose file'); } composeContent = data.content; @@ -931,7 +931,7 @@ services: if (!response.ok) { const data = await response.json(); - throw new Error(data.error || 'Failed to create stack'); + throw new Error((typeof data.error === 'string' ? data.error : data.message) || 'Failed to create stack'); } onSuccess(); @@ -1038,22 +1038,7 @@ services: requestBody.moveFromDir = moveFromDir; } - // Save compose file (with optional paths) - const response = await fetch( - appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/compose`, envId), - { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(requestBody) - } - ); - - const data = await response.json(); - - if (!response.ok) { - throw new Error(data.error || 'Failed to save compose file'); - } - + // Save env files BEFORE compose to ensure deploy reads fresh values // Save raw content to .env file (non-secrets only, comments preserved) const rawEnvResponse = await fetch( appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/env/raw`, envId), @@ -1066,7 +1051,7 @@ services: if (!rawEnvResponse.ok) { const rawEnvError = await rawEnvResponse.json().catch(() => ({ error: 'Failed to save environment file' })); - throw new Error(rawEnvError.error || 'Failed to save environment file'); + throw new Error((typeof rawEnvError.error === 'string' ? rawEnvError.error : rawEnvError.message) || 'Failed to save environment file'); } // Save only secrets to DB (non-secrets are in the .env file written above) @@ -1098,6 +1083,22 @@ services: ); } + // Save compose file (with optional paths) - after env so deploy reads fresh .env + const response = await fetch( + appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/compose`, envId), + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody) + } + ); + + const data = await response.json(); + + if (!response.ok) { + throw new Error((typeof data.error === 'string' ? data.error : data.message) || 'Failed to save compose file'); + } + isDirty = false; // Reset dirty flag after successful save onSuccess();