diff --git a/package.json b/package.json index eea5398..9bcddd6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dockhand", "private": true, - "version": "1.0.7", + "version": "1.0.8", "type": "module", "scripts": { "dev": "bunx --bun vite dev", diff --git a/src/lib/components/CodeEditor.svelte b/src/lib/components/CodeEditor.svelte index 0e76cb9..4440082 100644 --- a/src/lib/components/CodeEditor.svelte +++ b/src/lib/components/CodeEditor.svelte @@ -420,38 +420,61 @@ // Effect to update variable markers const updateMarkersEffect = StateEffect.define(); + // State field to store current markers (used for recalculation on doc change) + const currentMarkersField = StateField.define({ + create() { + return []; + }, + update(markers, tr) { + for (const effect of tr.effects) { + if (effect.is(updateMarkersEffect)) { + return effect.value; + } + } + return markers; + } + }); + // State field to track variable markers (gutter) - // IMPORTANT: Only updates via effects, not closure reference (fixes stale closure bug) + // Recalculates on doc change to avoid position mapping issues const variableMarkersField = StateField.define>({ create() { - // Start empty - markers will be pushed via effect return RangeSet.empty; }, update(markers, tr) { + // Check for marker updates first for (const effect of tr.effects) { if (effect.is(updateMarkersEffect)) { return createVariableDecorations(tr.state.doc, effect.value); } } - // Don't recalculate on docChanged - wait for explicit effect from parent + // Recalculate on doc change using stored markers + if (tr.docChanged) { + const currentMarkers = tr.state.field(currentMarkersField); + return createVariableDecorations(tr.state.doc, currentMarkers); + } return markers; } }); // State field to track value decorations (inline widgets) - // IMPORTANT: Only updates via effects, not closure reference (fixes stale closure bug) + // Recalculates on doc change to avoid widget duplication issues const valueDecorationsField = StateField.define({ create() { - // Start empty - decorations will be pushed via effect return Decoration.none; }, update(decorations, tr) { + // Check for marker updates first for (const effect of tr.effects) { if (effect.is(updateMarkersEffect)) { return createValueDecorations(tr.state.doc, effect.value); } } - // Don't recalculate on docChanged - wait for explicit effect from parent + // Recalculate on doc change using stored markers + if (tr.docChanged) { + const currentMarkers = tr.state.field(currentMarkersField); + return createValueDecorations(tr.state.doc, currentMarkers); + } return decorations; }, provide: f => EditorView.decorations.from(f) @@ -647,7 +670,7 @@ } // Always add variable markers gutter and value decorations (can be updated dynamically) - extensions.push(variableMarkersField, variableGutter, valueDecorationsField); + extensions.push(currentMarkersField, variableMarkersField, variableGutter, valueDecorationsField); const state = EditorState.create({ doc: value, @@ -666,14 +689,11 @@ // Skip onchange during programmatic value sync (only fire for user edits) const lastChangingTr = trs.findLast(tr => tr.docChanged); if (lastChangingTr && onchangeRef && !isSyncingExternalValue) { - // Defer callback to next microtask to avoid blocking input handling - // This allows key repeat to work properly + // Call synchronously to ensure parent state updates before any + // reactive $effect runs - this prevents race conditions on iPad Safari + // where paste content was being overwritten by stale external value const newContent = lastChangingTr.newDoc.toString(); - queueMicrotask(() => { - if (onchangeRef) { - onchangeRef(newContent); - } - }); + onchangeRef(newContent); } }; diff --git a/src/lib/components/StackEnvVarsEditor.svelte b/src/lib/components/StackEnvVarsEditor.svelte index e0e4ff3..54a7b10 100644 --- a/src/lib/components/StackEnvVarsEditor.svelte +++ b/src/lib/components/StackEnvVarsEditor.svelte @@ -104,7 +104,7 @@
- {#each variables as variable, index (`${index}-${variable.key}`)} + {#each variables as variable, index (index)} {@const source = getSource(variable.key)} {@const isVarRequired = isRequired(variable.key)} {@const isVarOptional = isOptional(variable.key)} diff --git a/src/lib/data/changelog.json b/src/lib/data/changelog.json index 62eb75b..5584409 100644 --- a/src/lib/data/changelog.json +++ b/src/lib/data/changelog.json @@ -1,4 +1,18 @@ [ + { + "version": "1.0.8", + "date": "2026-01-13", + "changes": [ + { "type": "fix", "text": "Fix imported stack working directory for relative volume paths" }, + { "type": "fix", "text": "Fix environment refresh after auth login" }, + { "type": "fix", "text": "Fix single container update clearing up all update badges" }, + { "type": "fix", "text": "Fix code editor paste issue on Safari on iPad" }, + { "type": "fix", "text": "Fix registry login failing due to Bun stdin API incompatibility" }, + { "type": "fix", "text": "Fix env var editor focus issues" }, + { "type": "fix", "text": "Fix git stack naming issues: validation, rename sync, and delete cleanup" } + ], + "imageTag": "fnsys/dockhand:v1.0.8" + }, { "version": "1.0.7", "date": "2026-01-06", diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts index cc2971b..f5f16e6 100644 --- a/src/lib/server/db.ts +++ b/src/lib/server/db.ts @@ -2643,6 +2643,25 @@ export async function deleteStackSource(stackName: string, environmentId?: numbe return true; } +export async function updateStackSourceName( + oldStackName: string, + newStackName: string, + environmentId?: number | null +): Promise { + await db.update(stackSources) + .set({ + stackName: newStackName, + updatedAt: new Date().toISOString() + }) + .where(and( + eq(stackSources.stackName, oldStackName), + environmentId !== undefined && environmentId !== null + ? eq(stackSources.environmentId, environmentId) + : isNull(stackSources.environmentId) + )); + return true; +} + // ============================================================================= // VULNERABILITY SCAN RESULTS // ============================================================================= diff --git a/src/lib/server/stacks.ts b/src/lib/server/stacks.ts index ad4dddb..c8a88e5 100644 --- a/src/lib/server/stacks.ts +++ b/src/lib/server/stacks.ts @@ -688,10 +688,9 @@ async function loginToRegistries(dockerHost?: string, logPrefix = '[Stack]'): Pr } ); - // Write password to stdin - const writer = proc.stdin.getWriter(); - await writer.write(new TextEncoder().encode(reg.password)); - await writer.close(); + // Write password to stdin (Bun's FileSink API) + proc.stdin.write(reg.password); + proc.stdin.end(); const exitCode = await proc.exited; @@ -717,6 +716,10 @@ interface ComposeCommandOptions { forceRecreate?: boolean; removeVolumes?: boolean; stackFiles?: Record; // All files to send to Hawser + /** Working directory for compose execution (for imported stacks) */ + workingDir?: string; + /** Full path to the compose file (for imported stacks, to avoid writing to internal dir) */ + composePath?: string; } /** @@ -724,6 +727,8 @@ interface ComposeCommandOptions { * * @param envVars - Non-secret environment variables (from .env file, passed for backward compat) * @param secretVars - Secret environment variables (injected via shell env, NEVER written to disk) + * @param workingDir - Optional working directory for compose execution (for imported stacks) + * @param customComposePath - Optional path to existing compose file (for imported stacks, skips writing) */ async function executeLocalCompose( operation: 'up' | 'down' | 'stop' | 'start' | 'restart' | 'pull', @@ -734,17 +739,33 @@ async function executeLocalCompose( secretVars?: Record, forceRecreate?: boolean, removeVolumes?: boolean, - envId?: number | null + envId?: number | null, + workingDir?: string, + customComposePath?: string ): Promise { const logPrefix = `[Stack:${stackName}]`; - // For operations that write (up), use getStackDir; for others, try to find existing first - const stackDir = operation === 'up' - ? await getStackDir(stackName, envId) - : (await findStackDir(stackName, envId) || await getStackDir(stackName, envId)); - mkdirSync(stackDir, { recursive: true }); - const composeFile = join(stackDir, 'docker-compose.yml'); - await Bun.write(composeFile, composeContent); + // Determine working directory and compose file path + // For imported stacks (custom paths), use the provided workingDir and composePath + // For internal stacks, use the default data directory + let stackDir: string; + let composeFile: string; + + if (customComposePath && workingDir) { + // Imported stack: use original location, don't copy files + stackDir = workingDir; + composeFile = customComposePath; + // Don't write to the compose file - it already exists at the custom location + // The user manages this file externally + } else { + // Internal stack: use default data directory + stackDir = operation === 'up' + ? await getStackDir(stackName, envId) + : (await findStackDir(stackName, envId) || await getStackDir(stackName, envId)); + mkdirSync(stackDir, { recursive: true }); + composeFile = join(stackDir, 'docker-compose.yml'); + await Bun.write(composeFile, composeContent); + } // Build spawn environment: // 1. Start with process.env @@ -1042,7 +1063,7 @@ async function executeComposeCommand( envVars?: Record, secretVars?: Record ): Promise { - const { stackName, envId, forceRecreate, removeVolumes, stackFiles } = options; + const { stackName, envId, forceRecreate, removeVolumes, stackFiles, workingDir, composePath } = options; // Get environment configuration const env = envId ? await getEnvironment(envId) : null; @@ -1058,7 +1079,9 @@ async function executeComposeCommand( secretVars, forceRecreate, removeVolumes, - envId + envId, + workingDir, + composePath ); } @@ -1089,7 +1112,9 @@ async function executeComposeCommand( secretVars, forceRecreate, removeVolumes, - envId + envId, + workingDir, + composePath ); } @@ -1104,7 +1129,9 @@ async function executeComposeCommand( secretVars, forceRecreate, removeVolumes, - envId + envId, + workingDir, + composePath ); } } @@ -1313,6 +1340,10 @@ export interface RequireComposeResult { secretVars?: Record; needsFileLocation?: boolean; error?: string; + /** Directory containing the compose file (for working directory) */ + stackDir?: string; + /** Full path to the compose file (for imported stacks) */ + composePath?: string; } /** @@ -1400,7 +1431,14 @@ async function requireComposeFile( // This ensures external edits to .env are respected during deployment const envVars = { ...dbNonSecretVars, ...fileEnvVars }; - return { success: true, content: composeResult.content!, envVars, secretVars }; + return { + success: true, + content: composeResult.content!, + envVars, + secretVars, + stackDir: composeResult.stackDir, + composePath: composeResult.composePath + }; } /** @@ -1418,7 +1456,13 @@ export async function startStack( return withContainerFallback(stackName, envId, 'start'); } - return executeComposeCommand('up', { stackName, envId }, result.content!, result.envVars, result.secretVars); + return executeComposeCommand( + 'up', + { stackName, envId, workingDir: result.stackDir, composePath: result.composePath }, + result.content!, + result.envVars, + result.secretVars + ); } /** @@ -1436,7 +1480,13 @@ export async function stopStack( return withContainerFallback(stackName, envId, 'stop'); } - return executeComposeCommand('stop', { stackName, envId }, result.content!, result.envVars, result.secretVars); + return executeComposeCommand( + 'stop', + { stackName, envId, workingDir: result.stackDir, composePath: result.composePath }, + result.content!, + result.envVars, + result.secretVars + ); } /** @@ -1454,7 +1504,13 @@ export async function restartStack( return withContainerFallback(stackName, envId, 'restart'); } - return executeComposeCommand('restart', { stackName, envId }, result.content!, result.envVars, result.secretVars); + return executeComposeCommand( + 'restart', + { stackName, envId, workingDir: result.stackDir, composePath: result.composePath }, + result.content!, + result.envVars, + result.secretVars + ); } /** @@ -1473,7 +1529,13 @@ export async function downStack( return withContainerFallback(stackName, envId, 'stop'); } - return executeComposeCommand('down', { stackName, envId, removeVolumes }, result.content!, result.envVars, result.secretVars); + return executeComposeCommand( + 'down', + { stackName, envId, removeVolumes, workingDir: result.stackDir, composePath: result.composePath }, + result.content!, + result.envVars, + result.secretVars + ); } /** @@ -1495,7 +1557,12 @@ export async function removeStack( const secretVars = await getSecretEnvVarsAsRecord(stackName, envId); const downResult = await executeComposeCommand( 'down', - { stackName, envId }, + { + stackName, + envId, + workingDir: composeResult.stackDir, + composePath: composeResult.composePath + }, composeResult.content!, envVars, secretVars @@ -1710,7 +1777,13 @@ export async function pullStackImages( }; } - return executeComposeCommand('pull', { stackName, envId }, result.content!, result.envVars, result.secretVars); + return executeComposeCommand( + 'pull', + { stackName, envId, workingDir: result.stackDir, composePath: result.composePath }, + result.content!, + result.envVars, + result.secretVars + ); } // ============================================================================= diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index c4aabd2..9c90fbc 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -12,7 +12,7 @@ import { SidebarProvider, SidebarTrigger } from '$lib/components/ui/sidebar'; import { startStatsCollection, stopStatsCollection } from '$lib/stores/stats'; import { connectSSE, disconnectSSE } from '$lib/stores/events'; - import { currentEnvironment } from '$lib/stores/environment'; + import { currentEnvironment, environments } from '$lib/stores/environment'; import { licenseStore, daysUntilExpiry } from '$lib/stores/license'; import { authStore } from '$lib/stores/auth'; import { themeStore, applyTheme } from '$lib/stores/theme'; @@ -51,6 +51,18 @@ } }); + // Refresh environments when user becomes authenticated + // This handles OIDC callback where login happens server-side + let wasAuthenticated = $state(false); + $effect(() => { + if (!$authStore.loading && $authStore.authenticated && !wasAuthenticated) { + environments.refresh(); + } + if (!$authStore.loading) { + wasAuthenticated = $authStore.authenticated; + } + }); + onMount(() => { // Apply theme from localStorage immediately (for flash-free loading) applyTheme(themeStore.get()); diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index ff79a1a..3c29030 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -12,7 +12,7 @@ import DraggableGrid, { type GridItemLayout } from './dashboard/DraggableGrid.svelte'; import { dashboardPreferences, dashboardData, GRID_COLS, GRID_ROW_HEIGHT, type TileItem } from '$lib/stores/dashboard'; import { currentEnvironment } from '$lib/stores/environment'; - import { IsMobile } from '$lib/hooks/is-mobile.svelte.js'; + import { IsMobile } from '$lib/hooks/is-mobile.svelte'; import type { EnvironmentStats } from './api/dashboard/stats/+server'; import { getLabelColor, getLabelBgColor } from '$lib/utils/label-colors'; diff --git a/src/routes/api/git/stacks/+server.ts b/src/routes/api/git/stacks/+server.ts index f769413..d944c5a 100644 --- a/src/routes/api/git/stacks/+server.ts +++ b/src/routes/api/git/stacks/+server.ts @@ -13,6 +13,9 @@ import { authorize } from '$lib/server/authorize'; import { registerSchedule } from '$lib/server/scheduler'; import { secureRandomBytes } from '$lib/server/crypto-fallback'; +// Stack name validation: must start with alphanumeric, can contain alphanumeric, hyphens, underscores +const STACK_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/; + export const GET: RequestHandler = async ({ url, cookies }) => { const auth = await authorize(cookies); @@ -49,6 +52,11 @@ export const POST: RequestHandler = async ({ request, cookies }) => { return json({ error: 'Stack name is required' }, { status: 400 }); } + const trimmedStackName = data.stackName.trim(); + if (!STACK_NAME_REGEX.test(trimmedStackName)) { + return json({ error: 'Stack name must start with a letter or number, and contain only letters, numbers, hyphens, and underscores' }, { status: 400 }); + } + // Either repositoryId or new repo details (url, branch) must be provided let repositoryId = data.repositoryId; @@ -98,7 +106,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => { } const gitStack = await createGitStack({ - stackName: data.stackName, + stackName: trimmedStackName, environmentId: data.environmentId || null, repositoryId: repositoryId, composePath: data.composePath || 'docker-compose.yml', @@ -112,7 +120,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => { // Create stack_sources entry so the stack appears in the list immediately await upsertStackSource({ - stackName: data.stackName, + stackName: trimmedStackName, environmentId: data.environmentId || null, sourceType: 'git', gitRepositoryId: repositoryId, diff --git a/src/routes/api/git/stacks/[id]/+server.ts b/src/routes/api/git/stacks/[id]/+server.ts index c05ea95..bb499f7 100644 --- a/src/routes/api/git/stacks/[id]/+server.ts +++ b/src/routes/api/git/stacks/[id]/+server.ts @@ -1,10 +1,13 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { getGitStack, updateGitStack, deleteGitStack } from '$lib/server/db'; +import { getGitStack, updateGitStack, deleteGitStack, deleteStackSource, updateStackSourceName } from '$lib/server/db'; import { deleteGitStackFiles, deployGitStack } from '$lib/server/git'; import { authorize } from '$lib/server/authorize'; import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler'; +// Stack name validation: must start with alphanumeric, can contain alphanumeric, hyphens, underscores +const STACK_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/; + export const GET: RequestHandler = async ({ params, cookies }) => { const auth = await authorize(cookies); @@ -43,6 +46,20 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => { } const data = await request.json(); + + // Validate stack name if it's being changed + if (data.stackName !== undefined) { + const trimmedStackName = data.stackName.trim(); + if (!trimmedStackName) { + return json({ error: 'Stack name is required' }, { status: 400 }); + } + if (!STACK_NAME_REGEX.test(trimmedStackName)) { + return json({ error: 'Stack name must start with a letter or number, and contain only letters, numbers, hyphens, and underscores' }, { status: 400 }); + } + data.stackName = trimmedStackName; + } + + const oldStackName = existing.stackName; const updated = await updateGitStack(id, { stackName: data.stackName, composePath: data.composePath, @@ -54,6 +71,11 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => { webhookSecret: data.webhookSecret }); + // If stack name changed, update the stack_sources record too + if (data.stackName && data.stackName !== oldStackName) { + await updateStackSourceName(oldStackName, data.stackName, existing.environmentId); + } + // Register or unregister schedule with croner if (updated.autoUpdate && updated.autoUpdateCron) { await registerSchedule(id, 'git_stack_sync', updated.environmentId); @@ -101,6 +123,9 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => { // Delete git files first deleteGitStackFiles(id); + // Delete the stack_sources record to free up the stack name + await deleteStackSource(existing.stackName, existing.environmentId); + // Delete from database await deleteGitStack(id); diff --git a/src/routes/containers/+page.svelte b/src/routes/containers/+page.svelte index 5984f2e..9b505f4 100644 --- a/src/routes/containers/+page.svelte +++ b/src/routes/containers/+page.svelte @@ -234,6 +234,10 @@ let batchUpdateContainerIds = $state([]); let batchUpdateContainerNames = $state>(new Map()); + // Single container update mode (doesn't overwrite batch list) + let singleUpdateContainerId = $state(null); + let singleUpdateContainerName = $state(null); + // Operation error state let operationError = $state<{ id: string; message: string } | null>(null); @@ -459,13 +463,16 @@ } function updateSingleContainer(containerId: string, containerName: string) { - batchUpdateContainerIds = [containerId]; - batchUpdateContainerNames = new Map([[containerId, containerName]]); + // Use single-container mode to avoid overwriting the batch list + singleUpdateContainerId = containerId; + singleUpdateContainerName = containerName; showBatchUpdateModal = true; } function handleBatchUpdateClose() { showBatchUpdateModal = false; + singleUpdateContainerId = null; + singleUpdateContainerName = null; updateCheckStatus = 'idle'; } @@ -481,13 +488,12 @@ } selectedContainers = new Set(); - // Keep blocked containers in the update list - they still have updates available - // Only remove successfully updated containers - const successSet = new Set(results.success); - batchUpdateContainerIds = batchUpdateContainerIds.filter(id => !successSet.has(id)); - for (const id of results.success) { - batchUpdateContainerNames.delete(id); - } + // Clear single-update mode + singleUpdateContainerId = null; + singleUpdateContainerName = null; + + // Reload pending updates from database to restore highlighting for remaining containers + loadPendingUpdates(); fetchContainers(); } @@ -2137,8 +2143,8 @@ {#if container.ports.length > 0} {@const uniquePorts = container.ports.filter((p, i, arr) => p.publicPort && arr.findIndex(x => x.publicPort === p.publicPort) === i)} - {#each uniquePorts.slice(0, 2) as port} + {#each uniquePorts as port} {@const url = getPortUrl(port.publicPort)} {#if url} {/if} {/each} - {#if uniquePorts.length > 2} - +{uniquePorts.length - 2} - {/if} {/if} {#if container.networks.length > 0} diff --git a/src/routes/stacks/GitStackModal.svelte b/src/routes/stacks/GitStackModal.svelte index 3d53581..e0ede93 100644 --- a/src/routes/stacks/GitStackModal.svelte +++ b/src/routes/stacks/GitStackModal.svelte @@ -88,6 +88,9 @@ let formError = $state(''); let formSaving = $state(false); let errors = $state<{ stackName?: string; repository?: string; repoName?: string; repoUrl?: string }>({}); + + // Stack name validation: must start with alphanumeric, can contain alphanumeric, hyphens, underscores + const STACK_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/; let copiedWebhookUrl = $state(false); let copiedWebhookSecret = $state(false); @@ -309,9 +312,13 @@ errors = {}; let hasErrors = false; - if (!formStackName.trim()) { + const trimmedStackName = formStackName.trim(); + if (!trimmedStackName) { errors.stackName = 'Stack name is required'; hasErrors = true; + } else if (!STACK_NAME_REGEX.test(trimmedStackName)) { + errors.stackName = 'Stack name must start with a letter or number, and contain only letters, numbers, hyphens, and underscores'; + hasErrors = true; } if (formRepoMode === 'existing' && !formRepositoryId) { diff --git a/vite.config.ts b/vite.config.ts index 5f1c578..0e7c2ba 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -18,6 +18,13 @@ interface DockerTarget { port?: number; hawserToken?: string; environmentId?: number; + // TLS configuration for mTLS connections + tls?: { + ca?: string; + cert?: string; + key?: string; + rejectUnauthorized?: boolean; + }; } interface EnvironmentRow { @@ -28,6 +35,11 @@ interface EnvironmentRow { host?: string; port?: number; hawser_token?: string; + protocol?: string; + tls_ca?: string; + tls_cert?: string; + tls_key?: string; + tls_skip_verify?: boolean | number; } // ============ Docker Target Resolution ============ @@ -51,11 +63,23 @@ function resolveDockerTarget( return { type: 'hawser-edge', environmentId: envId }; } + // Build TLS config if using HTTPS protocol + let tls: DockerTarget['tls'] | undefined; + if (env.protocol === 'https') { + tls = { + rejectUnauthorized: !env.tls_skip_verify + }; + if (env.tls_ca) tls.ca = env.tls_ca; + if (env.tls_cert) tls.cert = env.tls_cert; + if (env.tls_key) tls.key = env.tls_key; + } + return { type: 'tcp', host: env.host || 'localhost', port: env.port || 2375, - hawserToken: env.connection_type === 'hawser-standard' ? env.hawser_token : undefined + hawserToken: env.connection_type === 'hawser-standard' ? env.hawser_token : undefined, + tls }; } @@ -254,10 +278,23 @@ async function createExecForWs(containerId: string, cmd: string[], user: string, url = 'http://localhost/containers/' + containerId + '/exec'; fetchOpts.unix = target.socket; } else { - url = 'http://' + target.host + ':' + target.port + '/containers/' + containerId + '/exec'; + const protocol = target.tls ? 'https' : 'http'; + url = protocol + '://' + target.host + ':' + target.port + '/containers/' + containerId + '/exec'; if (target.hawserToken) { headers['X-Hawser-Token'] = target.hawserToken; } + // Add TLS options for mTLS connections + if (target.tls) { + fetchOpts.tls = { + sessionTimeout: 0, // Disable TLS session caching + servername: target.host, + rejectUnauthorized: target.tls.rejectUnauthorized ?? true + }; + if (target.tls.ca) fetchOpts.tls.ca = [target.tls.ca]; + if (target.tls.cert) fetchOpts.tls.cert = [target.tls.cert]; + if (target.tls.key) fetchOpts.tls.key = target.tls.key; + fetchOpts.keepalive = false; + } } const res = await fetch(url, fetchOpts); if (!res.ok) throw new Error('Failed to create exec: ' + (await res.text())); @@ -272,10 +309,23 @@ async function resizeExecForWs(execId: string, cols: number, rows: number, targe url = 'http://localhost/exec/' + execId + '/resize?h=' + rows + '&w=' + cols; fetchOpts.unix = target.socket; } else { - url = 'http://' + target.host + ':' + target.port + '/exec/' + execId + '/resize?h=' + rows + '&w=' + cols; + const protocol = target.tls ? 'https' : 'http'; + url = protocol + '://' + target.host + ':' + target.port + '/exec/' + execId + '/resize?h=' + rows + '&w=' + cols; if (target.hawserToken) { fetchOpts.headers = { 'X-Hawser-Token': target.hawserToken }; } + // Add TLS options for mTLS connections + if (target.tls) { + fetchOpts.tls = { + sessionTimeout: 0, + servername: target.host, + rejectUnauthorized: target.tls.rejectUnauthorized ?? true + }; + if (target.tls.ca) fetchOpts.tls.ca = [target.tls.ca]; + if (target.tls.cert) fetchOpts.tls.cert = [target.tls.cert]; + if (target.tls.key) fetchOpts.tls.key = target.tls.key; + fetchOpts.keepalive = false; + } } await fetch(url, fetchOpts); } catch { @@ -536,7 +586,17 @@ function webSocketPlugin(): Plugin { if (target.type === 'unix') { dockerStream = await Bun.connect({ unix: target.socket, socket: socketHandler }); } else if (target.type === 'tcp') { - dockerStream = await Bun.connect({ hostname: target.host, port: target.port, socket: socketHandler }); + // Build connection options with TLS if configured + const connectOpts: any = { hostname: target.host, port: target.port, socket: socketHandler }; + if (target.tls) { + connectOpts.tls = { + ca: target.tls.ca, + cert: target.tls.cert, + key: target.tls.key, + rejectUnauthorized: target.tls.rejectUnauthorized ?? true + }; + } + dockerStream = await Bun.connect(connectOpts); } dockerStreams.set(connId, { stream: dockerStream, execId, target, state, ws }); @@ -982,6 +1042,15 @@ export default defineConfig({ optimizeDeps: { include: ['lucide-svelte', '@xterm/xterm', '@xterm/addon-fit'] }, + resolve: { + dedupe: [ + '@codemirror/state', + '@codemirror/view', + '@codemirror/language', + '@lezer/common', + '@lezer/highlight' + ] + }, build: { target: 'esnext', minify: 'esbuild',