This commit is contained in:
jarek
2026-01-14 08:18:20 +01:00
parent f972378117
commit 107e9c3758
15 changed files with 317 additions and 65 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "dockhand",
"private": true,
"version": "1.0.7",
"version": "1.0.8",
"type": "module",
"scripts": {
"dev": "bunx --bun vite dev",
+34 -14
View File
@@ -420,38 +420,61 @@
// Effect to update variable markers
const updateMarkersEffect = StateEffect.define<VariableMarker[]>();
// State field to store current markers (used for recalculation on doc change)
const currentMarkersField = StateField.define<VariableMarker[]>({
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<RangeSet<GutterMarker>>({
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<DecorationSet>({
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);
}
};
+1 -1
View File
@@ -104,7 +104,7 @@
<div class="space-y-3">
<!-- Variables List -->
<div class="space-y-3">
{#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)}
+14
View File
@@ -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",
+19
View File
@@ -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<boolean> {
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
// =============================================================================
+96 -23
View File
@@ -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<string, string>; // 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<string, string>,
forceRecreate?: boolean,
removeVolumes?: boolean,
envId?: number | null
envId?: number | null,
workingDir?: string,
customComposePath?: string
): Promise<StackOperationResult> {
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<string, string>,
secretVars?: Record<string, string>
): Promise<StackOperationResult> {
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<string, string>;
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
);
}
// =============================================================================
+13 -1
View File
@@ -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());
+1 -1
View File
@@ -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';
+10 -2
View File
@@ -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,
+26 -1
View File
@@ -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);
+17 -11
View File
@@ -234,6 +234,10 @@
let batchUpdateContainerIds = $state<string[]>([]);
let batchUpdateContainerNames = $state<Map<string, string>>(new Map());
// Single container update mode (doesn't overwrite batch list)
let singleUpdateContainerId = $state<string | null>(null);
let singleUpdateContainerName = $state<string | null>(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 @@
<BatchUpdateModal
bind:open={showBatchUpdateModal}
containerIds={batchUpdateContainerIds}
containerNames={batchUpdateContainerNames}
containerIds={singleUpdateContainerId ? [singleUpdateContainerId] : batchUpdateContainerIds}
containerNames={singleUpdateContainerId && singleUpdateContainerName ? new Map([[singleUpdateContainerId, singleUpdateContainerName]]) : batchUpdateContainerNames}
{envId}
vulnerabilityCriteria={envHasScanning ? envVulnerabilityCriteria : 'never'}
onClose={handleBatchUpdateClose}
+3 -1
View File
@@ -8,6 +8,7 @@
import * as Card from '$lib/components/ui/card';
import { Loader2, LogIn, Shield, AlertCircle, Network, User, KeyRound, TriangleAlert } from 'lucide-svelte';
import { authStore } from '$lib/stores/auth';
import { environments } from '$lib/stores/environment';
import * as Alert from '$lib/components/ui/alert';
interface AuthProvider {
@@ -96,7 +97,8 @@
return;
}
// Success - redirect
// Success - refresh environments (they were cleared during pre-login fetch) then redirect
await environments.refresh();
goto(redirectUrl);
} catch (e) {
error = 'An unexpected error occurred';
+1 -4
View File
@@ -1724,7 +1724,7 @@
<!-- Clickable ports (dedupe by publicPort for IPv4/IPv6) -->
{#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}
<a
@@ -1744,9 +1744,6 @@
</span>
{/if}
{/each}
{#if uniquePorts.length > 2}
<span class="text-muted-foreground">+{uniquePorts.length - 2}</span>
{/if}
{/if}
<!-- Network with IP -->
{#if container.networks.length > 0}
+8 -1
View File
@@ -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) {
+73 -4
View File
@@ -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',