From a3cc26d958e7b2da4bb141726084a2e9dabbee84 Mon Sep 17 00:00:00 2001 From: jarek Date: Tue, 20 Jan 2026 15:39:08 +0100 Subject: [PATCH] 1.0.11 --- package.json | 2 +- src/hooks.server.ts | 8 + src/lib/components/CodeEditor.svelte | 7 + src/lib/data/changelog.json | 12 + src/lib/server/audit.ts | 6 +- src/lib/server/auth.ts | 12 +- src/lib/server/crypto-fallback.ts | 3 +- src/lib/server/db.ts | 174 ++++-- src/lib/server/db/connection.ts | 3 +- src/lib/server/db/drizzle.ts | 6 +- src/lib/server/docker.ts | 444 ++++++++++++-- src/lib/server/encryption.ts | 565 ++++++++++++++++++ src/lib/server/git.ts | 109 +++- src/lib/server/hawser.ts | 17 +- src/lib/server/license.ts | 3 +- src/lib/server/notifications.ts | 38 +- src/lib/server/scanner.ts | 38 +- src/lib/server/scheduler/index.ts | 49 +- .../scheduler/tasks/container-update.ts | 481 ++++++++++++++- src/lib/server/stack-scanner.ts | 5 +- src/lib/server/stacks.ts | 3 +- src/lib/server/subprocess-manager.ts | 9 +- .../containers/batch-update-stream/+server.ts | 142 +++-- .../api/containers/batch-update/+server.ts | 92 +-- src/routes/api/images/push/+server.ts | 9 +- src/routes/api/registry/catalog/+server.ts | 10 +- src/routes/api/registry/image/+server.ts | 1 + src/routes/api/registry/search/+server.ts | 2 + src/routes/api/registry/tags/+server.ts | 1 + .../containers/AutoUpdateSettings.svelte | 20 +- .../containers/ContainerSettingsTab.svelte | 7 + .../containers/EditContainerModal.svelte | 2 + src/routes/images/PushToRegistryModal.svelte | 6 +- .../registry/CopyToRegistryModal.svelte | 10 +- vite.config.ts | 22 +- 35 files changed, 2045 insertions(+), 273 deletions(-) create mode 100644 src/lib/server/encryption.ts diff --git a/package.json b/package.json index 5fc74d6..8be348e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dockhand", "private": true, - "version": "1.0.10", + "version": "1.0.11", "type": "module", "scripts": { "dev": "bunx --bun vite dev", diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 9fe2f29..021e261 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -7,6 +7,7 @@ import { checkLicenseExpiry, getHostname } from '$lib/server/license'; import { initCryptoFallback } from '$lib/server/crypto-fallback'; import { detectHostDataDir } from '$lib/server/host-path'; import { listContainers, removeContainer } from '$lib/server/docker'; +import { migrateCredentials } from '$lib/server/encryption'; import { rmSync, readdirSync, existsSync } from 'fs'; import { join } from 'path'; import type { HandleServerError, Handle } from '@sveltejs/kit'; @@ -69,6 +70,13 @@ if (!initialized) { setServerStartTime(); // Track when server started initDatabase(); + + // Migrate plain text credentials to encrypted storage + // This also handles key rotation if ENCRYPTION_KEY env var differs from key file + migrateCredentials().catch(err => { + console.error('[Startup] Failed to migrate credentials:', err); + }); + // Log hostname for license validation (set by entrypoint in Docker, or os.hostname() outside) console.log('Hostname for license validation:', getHostname()); diff --git a/src/lib/components/CodeEditor.svelte b/src/lib/components/CodeEditor.svelte index 4440082..b33d96c 100644 --- a/src/lib/components/CodeEditor.svelte +++ b/src/lib/components/CodeEditor.svelte @@ -385,6 +385,13 @@ for (let i = 0; i < lines.length; i++) { const line = lines[i]; + // Skip commented lines (YAML comments start with #) + const trimmedLine = line.trim(); + if (trimmedLine.startsWith('#')) { + pos += line.length + 1; + continue; + } + // Check if this line contains any of our marked variables for (const marker of markers) { // Match ${VAR_NAME} or ${VAR_NAME:-...} patterns diff --git a/src/lib/data/changelog.json b/src/lib/data/changelog.json index e8a4c62..40e65f1 100644 --- a/src/lib/data/changelog.json +++ b/src/lib/data/changelog.json @@ -1,4 +1,16 @@ [ + { + "version": "1.0.11", + "date": "2026-01-20", + "changes": [ + { "type": "fix", "text": "Encryption at rest for sensitive credentials (AES-256-GCM)" }, + { "type": "fix", "text": "Fix registry browsing and image push for registries with organization paths (e.g., registry.example.com/org)" }, + { "type": "fix", "text": "Fix security scan failing to parse scanner output" }, + { "type": "fix", "text": "Fix git sync stuck with sync_status set to running if app restarted during stack sync" }, + { "type": "fix", "text": "Fix updating via containers tab doesn't properly restart the container" } + ], + "imageTag": "fnsys/dockhand:v1.0.11" + }, { "version": "1.0.10", "date": "2026-01-18", diff --git a/src/lib/server/audit.ts b/src/lib/server/audit.ts index f4e7f35..34f275d 100644 --- a/src/lib/server/audit.ts +++ b/src/lib/server/audit.ts @@ -85,7 +85,8 @@ export async function audit( await logAuditEvent(data); } catch (error) { // Don't let audit logging errors break the main operation - console.error('Failed to log audit event:', error); + const errorMsg = error instanceof Error ? error.message : String(error); + console.error('[Audit] Failed to log event:', errorMsg); } } @@ -302,6 +303,7 @@ export async function auditAuth( try { await logAuditEvent(data); } catch (error) { - console.error('Failed to log audit event:', error); + const errorMsg = error instanceof Error ? error.message : String(error); + console.error('[Audit] Failed to log event:', errorMsg); } } diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index 708fe86..d4a9c30 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -704,7 +704,8 @@ async function tryLdapAuth( }; } catch (error: any) { try { await client.unbind(); } catch {} - console.error('LDAP authentication error:', error); + const errorMsg = error instanceof Error ? error.message : String(error); + console.error('[LDAP] Authentication error:', errorMsg); return { success: false, error: 'LDAP authentication failed' }; } } @@ -766,7 +767,8 @@ async function checkLdapGroupMembership( await client.unbind(); return searchEntries.length > 0; } catch (error) { - console.error('LDAP group membership check failed:', error); + const errorMsg = error instanceof Error ? error.message : String(error); + console.error('[LDAP] Group membership check failed:', errorMsg); try { await client.unbind(); } catch {} return false; } @@ -1214,7 +1216,8 @@ export async function buildOidcAuthorizationUrl( const authUrl = `${discovery.authorization_endpoint}?${params.toString()}`; return { url: authUrl, state }; } catch (error: any) { - console.error('Failed to build OIDC authorization URL:', error); + const errorMsg = error instanceof Error ? error.message : String(error); + console.error('[OIDC] Failed to build authorization URL:', errorMsg); return { error: error.message || 'Failed to initialize SSO' }; } } @@ -1415,7 +1418,8 @@ export async function handleOidcCallback( providerName: config.name }; } catch (error: any) { - console.error('OIDC callback error:', error); + const errorMsg = error instanceof Error ? error.message : String(error); + console.error('[OIDC] Callback error:', errorMsg); return { success: false, error: error.message || 'SSO authentication failed' }; } } diff --git a/src/lib/server/crypto-fallback.ts b/src/lib/server/crypto-fallback.ts index 3d4afb4..c95c1d7 100644 --- a/src/lib/server/crypto-fallback.ts +++ b/src/lib/server/crypto-fallback.ts @@ -118,7 +118,8 @@ export function initCryptoFallback(): boolean { } console.log('[Crypto] /dev/urandom fallback initialized successfully'); } catch (err) { - console.error('[Crypto] FATAL: Failed to read from /dev/urandom:', err); + const errorMsg = err instanceof Error ? err.message : String(err); + console.error('[Crypto] FATAL: Failed to read from /dev/urandom:', errorMsg); throw err; } } else { diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts index 72de2d0..24e58d9 100644 --- a/src/lib/server/db.ts +++ b/src/lib/server/db.ts @@ -78,6 +78,7 @@ import { } from './db/drizzle.js'; import type { AllGridPreferences, GridId, GridColumnPreferences } from '$lib/types'; +import { encrypt, decrypt } from './encryption.js'; // Re-export for backwards compatibility export { db, isPostgres, isSqlite }; @@ -112,7 +113,12 @@ export function initDatabase() { // ============================================================================= export async function getEnvironments(): Promise { - return db.select().from(environments).orderBy(asc(environments.name)); + const results = await db.select().from(environments).orderBy(asc(environments.name)); + return results.map((e: Environment) => ({ + ...e, + tlsKey: decrypt(e.tlsKey), + hawserToken: decrypt(e.hawserToken) + })); } export async function hasEnvironments(): Promise { @@ -122,12 +128,22 @@ export async function hasEnvironments(): Promise { export async function getEnvironment(id: number): Promise { const results = await db.select().from(environments).where(eq(environments.id, id)); - return results[0]; + if (!results[0]) return undefined; + return { + ...results[0], + tlsKey: decrypt(results[0].tlsKey), + hawserToken: decrypt(results[0].hawserToken) + }; } export async function getEnvironmentByName(name: string): Promise { const results = await db.select().from(environments).where(eq(environments.name, name)); - return results[0]; + if (!results[0]) return undefined; + return { + ...results[0], + tlsKey: decrypt(results[0].tlsKey), + hawserToken: decrypt(results[0].hawserToken) + }; } export async function createEnvironment(env: Omit): Promise { @@ -138,7 +154,7 @@ export async function createEnvironment(env: Omit): Promise { @@ -160,7 +180,7 @@ export async function updateEnvironment(id: number, env: Partial): if (env.protocol !== undefined) updateData.protocol = env.protocol; if (env.tlsCa !== undefined) updateData.tlsCa = env.tlsCa; if (env.tlsCert !== undefined) updateData.tlsCert = env.tlsCert; - if (env.tlsKey !== undefined) updateData.tlsKey = env.tlsKey; + if (env.tlsKey !== undefined) updateData.tlsKey = encrypt(env.tlsKey); if (env.tlsSkipVerify !== undefined) updateData.tlsSkipVerify = env.tlsSkipVerify; if (env.icon !== undefined) updateData.icon = env.icon; if (env.socketPath !== undefined) updateData.socketPath = env.socketPath; @@ -169,7 +189,7 @@ export async function updateEnvironment(id: number, env: Partial): if (env.highlightChanges !== undefined) updateData.highlightChanges = env.highlightChanges; if (env.labels !== undefined) updateData.labels = env.labels; if (env.connectionType !== undefined) updateData.connectionType = env.connectionType; - if (env.hawserToken !== undefined) updateData.hawserToken = env.hawserToken; + if (env.hawserToken !== undefined) updateData.hawserToken = encrypt(env.hawserToken); await db.update(environments).set(updateData).where(eq(environments.id, id)); return getEnvironment(id); @@ -183,19 +203,22 @@ export async function deleteEnvironment(id: number): Promise { try { await db.delete(hostMetrics).where(eq(hostMetrics.environmentId, id)); } catch (error) { - console.error('Failed to cleanup host metrics for environment:', error); + const errorMsg = error instanceof Error ? error.message : String(error); + console.error('[DB] Failed to cleanup host metrics for environment:', errorMsg); } try { await db.delete(stackEvents).where(eq(stackEvents.environmentId, id)); } catch (error) { - console.error('Failed to cleanup stack events for environment:', error); + const errorMsg = error instanceof Error ? error.message : String(error); + console.error('[DB] Failed to cleanup stack events for environment:', errorMsg); } try { await db.delete(autoUpdateSettings).where(eq(autoUpdateSettings.environmentId, id)); } catch (error) { - console.error('Failed to cleanup auto-update schedules for environment:', error); + const errorMsg = error instanceof Error ? error.message : String(error); + console.error('[DB] Failed to cleanup auto-update schedules for environment:', errorMsg); } await db.delete(environments).where(eq(environments.id, id)); @@ -207,17 +230,20 @@ export async function deleteEnvironment(id: number): Promise { // ============================================================================= export async function getRegistries(): Promise { - return db.select().from(registries).orderBy(desc(registries.isDefault), asc(registries.name)); + const results = await db.select().from(registries).orderBy(desc(registries.isDefault), asc(registries.name)); + return results.map((r: Registry) => ({ ...r, password: decrypt(r.password) })); } export async function getRegistry(id: number): Promise { const results = await db.select().from(registries).where(eq(registries.id, id)); - return results[0]; + if (!results[0]) return undefined; + return { ...results[0], password: decrypt(results[0].password) }; } export async function getDefaultRegistry(): Promise { const results = await db.select().from(registries).where(eq(registries.isDefault, true)); - return results[0]; + if (!results[0]) return undefined; + return { ...results[0], password: decrypt(results[0].password) }; } export async function createRegistry(registry: Omit): Promise { @@ -225,10 +251,13 @@ export async function createRegistry(registry: Omit): Promise { @@ -237,7 +266,7 @@ export async function updateRegistry(id: number, registry: Partial): P if (registry.name !== undefined) updateData.name = registry.name; if (registry.url !== undefined) updateData.url = registry.url; if (registry.username !== undefined) updateData.username = registry.username || null; - if (registry.password !== undefined) updateData.password = registry.password || null; + if (registry.password !== undefined) updateData.password = encrypt(registry.password) || null; if (registry.isDefault !== undefined) updateData.isDefault = registry.isDefault; await db.update(registries).set(updateData).where(eq(registries.id, id)); @@ -474,7 +503,7 @@ export interface ConfigSetData { export async function getConfigSets(): Promise { const rows = await db.select().from(configSets).orderBy(asc(configSets.name)); - return rows.map(row => ({ + return rows.map((row: typeof configSets.$inferSelect) => ({ ...row, envVars: row.envVars ? JSON.parse(row.envVars) : [], labels: row.labels ? JSON.parse(row.labels) : [], @@ -821,11 +850,35 @@ export interface AppriseConfig { urls: string[]; } +// Helper to encrypt sensitive fields in notification config +function encryptNotificationConfig(type: 'smtp' | 'apprise', config: SmtpConfig | AppriseConfig): string { + if (type === 'smtp') { + const smtpConfig = config as SmtpConfig; + return JSON.stringify({ + ...smtpConfig, + password: encrypt(smtpConfig.password) + }); + } + return JSON.stringify(config); +} + +// Helper to decrypt sensitive fields in notification config +function decryptNotificationConfig(type: string, configJson: string): any { + const config = JSON.parse(configJson); + if (type === 'smtp' && config.password) { + return { + ...config, + password: decrypt(config.password) + }; + } + return config; +} + export async function getNotificationSettings(): Promise { const rows = await db.select().from(notificationSettings).orderBy(desc(notificationSettings.createdAt)); - return rows.map(row => ({ + return rows.map((row: typeof notificationSettings.$inferSelect) => ({ ...row, - config: JSON.parse(row.config), + config: decryptNotificationConfig(row.type, row.config), eventTypes: row.eventTypes ? JSON.parse(row.eventTypes) : NOTIFICATION_EVENT_TYPES.map(e => e.id) })) as NotificationSettingData[]; } @@ -836,16 +889,16 @@ export async function getNotificationSetting(id: number): Promise e.id) } as NotificationSettingData; } export async function getEnabledNotificationSettings(): Promise { const rows = await db.select().from(notificationSettings).where(eq(notificationSettings.enabled, true)); - return rows.map(row => ({ + return rows.map((row: typeof notificationSettings.$inferSelect) => ({ ...row, - config: JSON.parse(row.config), + config: decryptNotificationConfig(row.type, row.config), eventTypes: row.eventTypes ? JSON.parse(row.eventTypes) : NOTIFICATION_EVENT_TYPES.map(e => e.id) })) as NotificationSettingData[]; } @@ -862,7 +915,7 @@ export async function createNotificationSetting(data: { type: data.type, name: data.name, enabled: data.enabled !== false, - config: JSON.stringify(data.config), + config: encryptNotificationConfig(data.type, data.config), eventTypes: JSON.stringify(eventTypes) }).returning(); return getNotificationSetting(result[0].id) as Promise; @@ -881,7 +934,7 @@ export async function updateNotificationSetting(id: number, data: { if (data.name !== undefined) updateData.name = data.name; if (data.enabled !== undefined) updateData.enabled = data.enabled; - if (data.config !== undefined) updateData.config = JSON.stringify(data.config); + if (data.config !== undefined) updateData.config = encryptNotificationConfig(existing.type, data.config); if (data.eventTypes !== undefined) updateData.eventTypes = JSON.stringify(data.eventTypes); await db.update(notificationSettings).set(updateData).where(eq(notificationSettings.id, id)); @@ -931,7 +984,7 @@ export async function getEnvironmentNotifications(environmentId: number): Promis .where(eq(environmentNotifications.environmentId, environmentId)) .orderBy(asc(notificationSettings.name)); - return rows.map(row => ({ + return rows.map((row: any) => ({ ...row, eventTypes: row.eventTypes ? JSON.parse(row.eventTypes) : NOTIFICATION_EVENT_TYPES.map(e => e.id) })) as EnvironmentNotificationData[]; @@ -1039,7 +1092,7 @@ export async function getEnabledEnvironmentNotifications( .map(row => ({ ...row, eventTypes: row.eventTypes ? JSON.parse(row.eventTypes) : NOTIFICATION_EVENT_TYPES.map(e => e.id), - config: JSON.parse(row.config) + config: decryptNotificationConfig(row.channelType ?? 'apprise', row.config) })) .filter(row => !eventType || row.eventTypes.includes(eventType)) as (EnvironmentNotificationData & { config: any })[]; } @@ -1591,6 +1644,7 @@ export async function getLdapConfigs(): Promise { const results = await db.select().from(ldapConfig).orderBy(asc(ldapConfig.name)); return results.map((row: any) => ({ ...row, + bindPassword: decrypt(row.bindPassword), roleMappings: row.roleMappings ? JSON.parse(row.roleMappings) : null })) as LdapConfigData[]; } @@ -1601,6 +1655,7 @@ export async function getLdapConfig(id: number): Promise const row = results[0] as any; return { ...row, + bindPassword: decrypt(row.bindPassword), roleMappings: row.roleMappings ? JSON.parse(row.roleMappings) : null } as LdapConfigData; } @@ -1611,7 +1666,7 @@ export async function createLdapConfig(data: Omit if (data.enabled !== undefined) updateData.enabled = data.enabled; if (data.serverUrl !== undefined) updateData.serverUrl = data.serverUrl; if (data.bindDn !== undefined) updateData.bindDn = data.bindDn || null; - if (data.bindPassword !== undefined) updateData.bindPassword = data.bindPassword || null; + if (data.bindPassword !== undefined) updateData.bindPassword = encrypt(data.bindPassword) || null; if (data.baseDn !== undefined) updateData.baseDn = data.baseDn; if (data.userFilter !== undefined) updateData.userFilter = data.userFilter; if (data.usernameAttribute !== undefined) updateData.usernameAttribute = data.usernameAttribute; @@ -1689,6 +1744,7 @@ export async function getOidcConfigs(): Promise { const rows = await db.select().from(oidcConfig).orderBy(asc(oidcConfig.name)); return rows.map(row => ({ ...row, + clientSecret: decrypt(row.clientSecret) ?? '', roleMappings: row.roleMappings ? JSON.parse(row.roleMappings) : undefined })) as OidcConfigData[]; } @@ -1698,6 +1754,7 @@ export async function getOidcConfig(id: number): Promise if (!results[0]) return null; return { ...results[0], + clientSecret: decrypt(results[0].clientSecret) ?? '', roleMappings: results[0].roleMappings ? JSON.parse(results[0].roleMappings) : undefined } as OidcConfigData; } @@ -1708,7 +1765,7 @@ export async function createOidcConfig(data: Omit if (data.enabled !== undefined) updateData.enabled = data.enabled; if (data.issuerUrl !== undefined) updateData.issuerUrl = data.issuerUrl; if (data.clientId !== undefined) updateData.clientId = data.clientId; - if (data.clientSecret !== undefined) updateData.clientSecret = data.clientSecret; + if (data.clientSecret !== undefined) updateData.clientSecret = encrypt(data.clientSecret); if (data.redirectUri !== undefined) updateData.redirectUri = data.redirectUri; if (data.scopes !== undefined) updateData.scopes = data.scopes; if (data.usernameClaim !== undefined) updateData.usernameClaim = data.usernameClaim; @@ -1768,12 +1825,24 @@ export interface GitCredentialData { } export async function getGitCredentials(): Promise { - return db.select().from(gitCredentials).orderBy(asc(gitCredentials.name)) as Promise; + const results = await db.select().from(gitCredentials).orderBy(asc(gitCredentials.name)); + return results.map(r => ({ + ...r, + password: decrypt(r.password), + sshPrivateKey: decrypt(r.sshPrivateKey), + sshPassphrase: decrypt(r.sshPassphrase) + })) as GitCredentialData[]; } export async function getGitCredential(id: number): Promise { const results = await db.select().from(gitCredentials).where(eq(gitCredentials.id, id)); - return results[0] as GitCredentialData || null; + if (!results[0]) return null; + return { + ...results[0], + password: decrypt(results[0].password), + sshPrivateKey: decrypt(results[0].sshPrivateKey), + sshPassphrase: decrypt(results[0].sshPassphrase) + } as GitCredentialData; } export async function createGitCredential(data: { @@ -1788,9 +1857,9 @@ export async function createGitCredential(data: { name: data.name, authType: data.authType, username: data.username || null, - password: data.password || null, - sshPrivateKey: data.sshPrivateKey || null, - sshPassphrase: data.sshPassphrase || null + password: encrypt(data.password) || null, + sshPrivateKey: encrypt(data.sshPrivateKey) || null, + sshPassphrase: encrypt(data.sshPassphrase) || null }).returning(); return getGitCredential(result[0].id) as Promise; } @@ -1803,9 +1872,9 @@ export async function updateGitCredential(id: number, data: Partial ({ - id: row.id, - stackName: row.stackName, - environmentId: row.environmentId, - key: row.key, - value: maskSecrets && row.isSecret ? '***' : row.value, - isSecret: row.isSecret ?? false, - createdAt: row.createdAt ?? new Date().toISOString(), - updatedAt: row.updatedAt ?? new Date().toISOString() - })); + return results.map(row => { + // Decrypt secret values (decrypt handles both encrypted and plain text) + const decryptedValue = row.isSecret ? (decrypt(row.value) ?? '') : row.value; + return { + id: row.id, + stackName: row.stackName, + environmentId: row.environmentId, + key: row.key, + value: maskSecrets && row.isSecret ? '***' : decryptedValue, + isSecret: row.isSecret ?? false, + createdAt: row.createdAt ?? new Date().toISOString(), + updatedAt: row.updatedAt ?? new Date().toISOString() + }; + }); } /** @@ -4309,7 +4382,8 @@ export async function setStackEnvVars( stackName, environmentId, key: v.key, - value: v.value, + // Encrypt values that are marked as secrets + value: v.isSecret ? (encrypt(v.value) ?? '') : v.value, isSecret: v.isSecret ?? false, createdAt: now, updatedAt: now diff --git a/src/lib/server/db/connection.ts b/src/lib/server/db/connection.ts index 957fe04..27abb17 100644 --- a/src/lib/server/db/connection.ts +++ b/src/lib/server/db/connection.ts @@ -153,7 +153,8 @@ export const sql = createConnection(); // Initialize schema (runs async but we handle it) initializeSchema(sql).catch((error) => { - console.error('Database initialization failed:', error); + const errorMsg = error instanceof Error ? error.message : String(error); + console.error('[DB] Database initialization failed:', errorMsg); process.exit(1); }); diff --git a/src/lib/server/db/drizzle.ts b/src/lib/server/db/drizzle.ts index 8eb7eeb..1c48e7c 100644 --- a/src/lib/server/db/drizzle.ts +++ b/src/lib/server/db/drizzle.ts @@ -194,7 +194,8 @@ function readMigrationJournal(migrationsFolder: string): MigrationJournal | null } catch (error) { const config = getConfig(); if (config.verboseLogging) { - console.error('Failed to read migration journal:', error); + const errorMsg = error instanceof Error ? error.message : String(error); + console.error('[DB] Failed to read migration journal:', errorMsg); } return null; } @@ -986,7 +987,8 @@ export async function getDatabaseSchemaVersion(): Promise { } return { version: null, date: null }; } catch (e) { - console.error('Error getting schema version:', e); + const errorMsg = e instanceof Error ? e.message : String(e); + console.error('[DB] Error getting schema version:', errorMsg); return { version: null, date: null }; } } diff --git a/src/lib/server/docker.ts b/src/lib/server/docker.ts index fb2b235..605fb88 100644 --- a/src/lib/server/docker.ts +++ b/src/lib/server/docker.ts @@ -835,24 +835,44 @@ export interface DeviceMapping { 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]: { HostPort: string } }; + ports?: { [key: string]: { HostIp?: string; HostPort: string } }; volumes?: { [key: string]: {} }; volumeBinds?: string[]; env?: string[]; labels?: { [key: string]: string }; cmd?: string[]; + entrypoint?: string[]; + workingDir?: string; restartPolicy?: string; restartMaxRetries?: number; networkMode?: string; 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; user?: string; privileged?: boolean; healthcheck?: HealthcheckConfig; memory?: number; memoryReservation?: number; + memorySwap?: number; cpuShares?: number; cpuQuota?: number; cpuPeriod?: number; @@ -865,6 +885,56 @@ export interface CreateContainerOptions { 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; + // 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) { @@ -929,14 +999,74 @@ export async function createContainer(options: CreateContainerOptions, envId?: n if (options.networkMode) { containerConfig.HostConfig.NetworkMode = options.networkMode; + + // Build endpoint config for primary network with aliases, static IP, and gateway priority + const hasNetworkConfig = options.networkAliases?.length || options.networkIpv4Address || options.networkIpv6Address || options.networkGwPriority !== undefined; + if (hasNetworkConfig) { + const endpointConfig: any = {}; + + if (options.networkAliases && options.networkAliases.length > 0) { + 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; + } + } + + // Gateway priority (Docker Engine 28+) + if (options.networkGwPriority !== undefined) { + endpointConfig.GwPriority = options.networkGwPriority; + } + + containerConfig.NetworkingConfig = { + EndpointsConfig: { + [options.networkMode]: endpointConfig + } + }; + } } if (options.networks && options.networks.length > 0) { containerConfig.HostConfig.NetworkMode = options.networks[0]; + + // Build endpoint configs for all networks + const endpointsConfig: Record = {}; + + for (const network of options.networks) { + const isFirstNetwork = network === options.networks[0]; + const endpointConfig: any = {}; + + // Apply aliases, static IP, and gateway priority only to the first (primary) network + if (isFirstNetwork) { + if (options.networkAliases && options.networkAliases.length > 0) { + 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; + } + } + // Gateway priority (Docker Engine 28+) + if (options.networkGwPriority !== undefined) { + endpointConfig.GwPriority = options.networkGwPriority; + } + } + + endpointsConfig[network] = endpointConfig; + } + containerConfig.NetworkingConfig = { - EndpointsConfig: Object.fromEntries( - options.networks.map(network => [network, {}]) - ) + EndpointsConfig: endpointsConfig }; } @@ -1000,6 +1130,163 @@ export async function createContainer(options: CreateContainerOptions, envId?: n })); } + // 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 + if (options.macAddress) { + containerConfig.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) { + 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; + } + const result = await dockerJsonRequest<{ Id: string }>( `/containers/create?name=${encodeURIComponent(options.name)}`, { @@ -1108,7 +1395,8 @@ export async function pullImage(imageName: string, onProgress?: (data: any) => v console.log(`[Pull] No credentials found for ${registry}`); } } catch (e) { - console.error(`[Pull] Failed to lookup credentials:`, e); + const errorMsg = e instanceof Error ? e.message : String(e); + console.error(`[Pull] Failed to lookup credentials:`, errorMsg); } // Use streaming: true for longer timeout on edge environments @@ -1220,9 +1508,38 @@ function parseImageReference(imageName: string): { registry: string; repo: strin 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 } { + // 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 }; + } + const host = trimmed.substring(0, slashIndex); + const path = trimmed.substring(slashIndex); // includes leading / + return { host, path, fullRegistry: trimmed }; +} + /** * Find registry credentials from Dockhand's stored registries. - * Matches by registry host (url field). + * 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 { @@ -1230,10 +1547,16 @@ async function findRegistryCredentials(registryHost: string): Promise<{ username const { getRegistries } = await import('./db.js'); const registries = await getRegistries(); + const requested = parseRegistryUrl(registryHost); + for (const reg of registries) { - // Match by URL - extract host from stored URL - const storedHost = reg.url.replace(/^https?:\/\//, '').replace(/\/.*$/, ''); - if (storedHost === registryHost || reg.url.includes(registryHost)) { + 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) { return { username: reg.username, password: reg.password }; } @@ -1241,13 +1564,13 @@ async function findRegistryCredentials(registryHost: string): Promise<{ username } // Also check for Docker Hub variations - if (registryHost === 'index.docker.io' || registryHost === 'registry-1.docker.io') { + if (requested.host === 'index.docker.io' || requested.host === 'registry-1.docker.io') { for (const reg of registries) { - const storedHost = reg.url.replace(/^https?:\/\//, '').replace(/\/.*$/, ''); + const stored = parseRegistryUrl(reg.url); // Match all Docker Hub URL variations - if (storedHost === 'docker.io' || storedHost === 'hub.docker.com' || - storedHost === 'registry.hub.docker.com' || storedHost === 'index.docker.io' || - storedHost === 'registry-1.docker.io') { + if (stored.host === 'docker.io' || stored.host === 'hub.docker.com' || + stored.host === 'registry.hub.docker.com' || stored.host === 'index.docker.io' || + stored.host === 'registry-1.docker.io') { if (reg.username && reg.password) { return { username: reg.username, password: reg.password }; } @@ -1257,7 +1580,8 @@ async function findRegistryCredentials(registryHost: string): Promise<{ username return null; } catch (e) { - console.error('Failed to lookup registry credentials:', e); + const errorMsg = e instanceof Error ? e.message : String(e); + console.error('[Registry] Failed to lookup credentials:', errorMsg); return null; } } @@ -1352,7 +1676,8 @@ async function getRegistryBearerToken(registry: string, repo: string): Promise { try { - // Normalize URL - let baseUrl = registryUrl; - if (!baseUrl.startsWith('http')) { - baseUrl = `https://${baseUrl}`; - } - baseUrl = baseUrl.replace(/\/$/, ''); + // Parse URL to extract host (V2 API is always at the host root) + const parsed = parseRegistryUrl(registryUrl); + const apiBaseUrl = `https://${parsed.host}`; - // Step 1: Challenge request to /v2/ - const challengeResponse = await fetch(`${baseUrl}/v2/`, { + // 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' } }); @@ -1458,7 +1780,8 @@ export async function getRegistryAuthHeader( return token ? `Bearer ${token}` : null; } catch (e) { - console.error('Failed to get registry auth header:', e); + const errorMsg = e instanceof Error ? e.message : String(e); + console.error('[Registry] Failed to get auth header:', errorMsg); return null; } } @@ -1469,27 +1792,26 @@ export async function getRegistryAuthHeader( * * @param registry - Registry object from database * @param scope - Token scope (e.g., 'registry:catalog:*' or 'repository:user/repo:pull') - * @returns { baseUrl, authHeader } - Normalized URL and auth header (or null) + * @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; authHeader: string | null }> { - // Normalize URL - let baseUrl = registry.url; - if (!baseUrl.startsWith('http')) { - baseUrl = `https://${baseUrl}`; - } - baseUrl = baseUrl.replace(/\/$/, ''); +): 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 = `https://${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(baseUrl, scope, credentials); + const authHeader = await getRegistryAuthHeader(registry.url, scope, credentials); - return { baseUrl, authHeader }; + return { baseUrl, orgPath: parsed.path, authHeader }; } /** @@ -2037,17 +2359,51 @@ export async function createNetwork(options: CreateNetworkOptions, envId?: numbe } // 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 + 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({ Container: containerId }) + body: JSON.stringify(body) }, envId ); @@ -2420,6 +2776,18 @@ export async function runContainerWithStreaming(options: { await streamLocalStderr(containerId, options.envId, options.onStderr); } + // Wait for container to fully exit before fetching stdout + // The stderr stream may close before the container finishes writing to stdout + // Use a timeout to prevent hanging if something goes wrong (container should already be exited) + const waitPromise = dockerFetch(`/containers/${containerId}/wait`, { method: 'POST' }, options.envId); + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('Container wait timeout after 10s')), 10000) + ); + await Promise.race([waitPromise, timeoutPromise]).catch((err) => { + // Log but don't fail - container might already be gone or stderr stream was reliable + console.warn(`[runContainerWithStreaming] Wait warning: ${err.message}`); + }); + // Container has exited. Now fetch stdout reliably (no race condition). const stdout = await fetchContainerStdout(containerId, config, options.envId); return stdout; diff --git a/src/lib/server/encryption.ts b/src/lib/server/encryption.ts new file mode 100644 index 0000000..ed09ea0 --- /dev/null +++ b/src/lib/server/encryption.ts @@ -0,0 +1,565 @@ +/** + * Credential Encryption Module + * + * Provides AES-256-GCM encryption for sensitive credentials at rest. + * 1. No file, no env var: Generate key, save to file (initial setup) + * 2. File exists, no env var: Use file key (unchanged) + * 3. No file, env var set: Use env var key, do NOT save to file + * 4. File exists, env var set (same key): Use key, delete file (env var is source of truth) + * 5. File exists, env var set (different key): Re-encrypt with env var key, delete file + * + * Once a user provides ENCRYPTION_KEY, the key file is removed - the key lives only in memory + */ + +import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto'; +import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'node:fs'; +import { join, dirname } from 'node:path'; + +// ============================================================================= +// CONSTANTS +// ============================================================================= + +/** Encryption algorithm: AES-256 with GCM mode (authenticated encryption) */ +const ALGORITHM = 'aes-256-gcm'; + +/** Initialization vector length in bytes */ +const IV_LENGTH = 12; + +/** Authentication tag length in bytes */ +const AUTH_TAG_LENGTH = 16; + +/** Encryption key length in bytes (256 bits) */ +const KEY_LENGTH = 32; + +/** Prefix for encrypted values (version 1) */ +const ENCRYPTED_PREFIX = 'enc:v1:'; + +/** File name for auto-generated encryption key */ +const KEY_FILE_NAME = '.encryption_key'; + +let cachedKey: Buffer | null = null; + +/** Pending key rotation state (set when env var differs from file) */ +let pendingKeyRotation: { oldKey: Buffer; newKey: Buffer } | null = null; + +function getDataDir(): string { + return process.env.DATA_DIR || './data'; +} + +/** + * Get or create the encryption key. + * + * Hybrid key management approach: + * 1. No file, no env var: Generate key, save to file (initial setup) + * 2. File exists, no env var: Use file key (unchanged) + * 3. No file, env var set: Use env var key, do NOT save to file + * 4. File exists, env var set (same key): Use key, delete file (env var is source of truth) + * 5. File exists, env var set (different key): Re-encrypt with env var key, delete file after migration + * + * Once user provides ENCRYPTION_KEY, the key file is removed - the key lives + * only in memory from the environment variable. + */ +function getOrCreateKey(): Buffer { + // Return cached key if available + if (cachedKey) { + return cachedKey; + } + + const dataDir = getDataDir(); + const keyPath = join(dataDir, KEY_FILE_NAME); + const envKey = process.env.ENCRYPTION_KEY; + + // 1. File exists? + if (existsSync(keyPath)) { + try { + const fileKey = readFileSync(keyPath); + if (fileKey.length !== KEY_LENGTH) { + throw new Error(`Key file has invalid length: expected ${KEY_LENGTH}, got ${fileKey.length}`); + } + + // Env var also set? Env var takes over, file will be deleted + if (envKey) { + try { + const envKeyBuffer = Buffer.from(envKey, 'base64'); + if (envKeyBuffer.length !== KEY_LENGTH) { + console.warn('[Encryption] WARNING: ENCRYPTION_KEY env var has invalid length (ignored)'); + // Fall through to use file key + } else if (!fileKey.equals(envKeyBuffer)) { + // Different key - trigger key rotation mode + // File will be deleted after re-encryption in migrateCredentials() + console.log('[Encryption] Key change detected - will re-encrypt and remove key file'); + pendingKeyRotation = { oldKey: fileKey, newKey: envKeyBuffer }; + // Return OLD key for decryption first + cachedKey = fileKey; + return cachedKey; + } else { + // Same key - delete file immediately, env var is now source of truth + try { + unlinkSync(keyPath); + console.log('[Encryption] Using ENCRYPTION_KEY from environment, removed key file'); + } catch (unlinkError) { + const msg = unlinkError instanceof Error ? unlinkError.message : String(unlinkError); + console.warn(`[Encryption] Could not remove key file: ${msg}`); + } + cachedKey = envKeyBuffer; + return cachedKey; + } + } catch { + console.warn('[Encryption] WARNING: ENCRYPTION_KEY env var is invalid (ignored)'); + } + } + + // No env var or invalid env var - use file key + cachedKey = fileKey; + console.log('[Encryption] Using encryption key from', keyPath); + return cachedKey; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to read encryption key from ${keyPath}: ${msg}`); + } + } + + // 2. No file - env var set? Use it WITHOUT saving to file + if (envKey) { + try { + const keyBuffer = Buffer.from(envKey, 'base64'); + if (keyBuffer.length !== KEY_LENGTH) { + throw new Error(`ENCRYPTION_KEY must be exactly ${KEY_LENGTH} bytes when decoded`); + } + cachedKey = keyBuffer; + console.log('[Encryption] Using ENCRYPTION_KEY from environment (not persisted to disk)'); + return cachedKey; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + throw new Error(`Invalid ENCRYPTION_KEY: ${msg}`); + } + } + + // 3. No file, no env var - generate new key and save to file (initial setup) + // Ensure data directory exists before writing + if (!existsSync(dataDir)) { + mkdirSync(dataDir, { recursive: true }); + } + + console.log('[Encryption] Generating new encryption key...'); + cachedKey = randomBytes(KEY_LENGTH); + + // Save key with restricted permissions (0600 = owner read/write only) + try { + writeFileSync(keyPath, cachedKey, { mode: 0o600 }); + console.log('[Encryption] Saved new encryption key to', keyPath); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.error(`[Encryption] Warning: Failed to save encryption key to ${keyPath}: ${msg}`); + console.error('[Encryption] Encryption will work for this session but keys will be regenerated on restart'); + } + + return cachedKey; +} + +// ============================================================================= +// ENCRYPTION / DECRYPTION +// ============================================================================= + +/** + * Encrypt a plain text value using AES-256-GCM. + * + * @param plaintext - The value to encrypt (or null/empty) + * @returns Encrypted value with "enc:v1:" prefix, or null/empty if input was null/empty + * + * Format: enc:v1: + */ +export function encrypt(plaintext: string | null | undefined): string | null { + // Pass through null/undefined/empty values + if (plaintext === null || plaintext === undefined || plaintext === '') { + return plaintext as string | null; + } + + // Don't double-encrypt + if (plaintext.startsWith(ENCRYPTED_PREFIX)) { + return plaintext; + } + + const key = getOrCreateKey(); + const iv = randomBytes(IV_LENGTH); + + const cipher = createCipheriv(ALGORITHM, key, iv); + const ciphertext = Buffer.concat([ + cipher.update(plaintext, 'utf8'), + cipher.final() + ]); + + const authTag = cipher.getAuthTag(); + + // Combine: iv (12 bytes) + authTag (16 bytes) + ciphertext + const combined = Buffer.concat([iv, authTag, ciphertext]); + + return ENCRYPTED_PREFIX + combined.toString('base64'); +} + +/** + * Decrypt a value that may be encrypted or plain text. + * + * If the value doesn't have the "enc:v1:" prefix, it's assumed to be plain text and returned as-is. + * + * @param value - The value to decrypt (encrypted with prefix, plain text, null, or empty) + * @returns Decrypted value, or the original value if not encrypted, or null if input was null + */ +export function decrypt(value: string | null | undefined): string | null { + // Pass through null/undefined/empty values + if (value === null || value === undefined || value === '') { + return value as string | null; + } + + // BACKWARDS COMPATIBILITY: If no prefix, it's plain text - return as-is + if (!value.startsWith(ENCRYPTED_PREFIX)) { + return value; + } + + // Extract the base64 payload after the prefix + const payload = value.substring(ENCRYPTED_PREFIX.length); + + let combined: Buffer; + try { + combined = Buffer.from(payload, 'base64'); + } catch { + console.error('[Encryption] Failed to decode base64 payload'); + // Return original value to avoid data loss + return value; + } + + // Validate minimum length: iv (12) + authTag (16) + at least 1 byte ciphertext + if (combined.length < IV_LENGTH + AUTH_TAG_LENGTH + 1) { + console.error('[Encryption] Encrypted payload is too short'); + return value; + } + + // Extract components + const iv = combined.subarray(0, IV_LENGTH); + const authTag = combined.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH); + const ciphertext = combined.subarray(IV_LENGTH + AUTH_TAG_LENGTH); + + try { + const key = getOrCreateKey(); + const decipher = createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + + const decrypted = Buffer.concat([ + decipher.update(ciphertext), + decipher.final() + ]); + + return decrypted.toString('utf8'); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.error(`[Encryption] Decryption failed: ${msg}`); + // Return original value to avoid data loss (might be corrupted or wrong key) + return value; + } +} + +/** + * Check if a value is encrypted (has the encryption prefix). + * + * @param value - The value to check + * @returns true if the value appears to be encrypted + */ +export function isEncrypted(value: string | null | undefined): boolean { + return typeof value === 'string' && value.startsWith(ENCRYPTED_PREFIX); +} + +/** + * Generate a new encryption key and return it as base64. + * Useful for generating ENCRYPTION_KEY environment variable values. + * + * @returns Base64-encoded 32-byte encryption key + */ +export function generateKey(): string { + return randomBytes(KEY_LENGTH).toString('base64'); +} + +/** + * Clear the cached encryption key. + * Primarily for testing purposes. + */ +export function clearKeyCache(): void { + cachedKey = null; + pendingKeyRotation = null; +} + +/** + * Initialize encryption and migrate unencrypted credentials. + * + * 1. Ensures encryption key exists (generates or loads from file/env var) + * 2. Checks for pending key rotation (re-encrypts with new key, removes key file) + * 3. Encrypts any values that don't have the "enc:v1:" prefix + * + * This is idempotent - safe to call on every startup. + */ +export async function migrateCredentials(): Promise { + // IMPORTANT: Always initialize the key on startup, even if there are no credentials yet. + // This ensures the key file is created before any credentials are added. + getOrCreateKey(); + + console.log('[Encryption] Checking for unencrypted credentials...'); + + // Import database dynamically to avoid circular dependency + const { + db, + eq, + registries, + gitCredentials, + environments, + oidcConfig, + ldapConfig, + notificationSettings, + stackEnvironmentVariables + } = await import('./db/drizzle.js'); + + let migrated = 0; + const keyPath = join(getDataDir(), KEY_FILE_NAME); + + // Check for key rotation first + if (pendingKeyRotation) { + console.log('[Encryption] Performing key rotation - re-encrypting all credentials...'); + + // Decrypt everything with old key, then switch to new key + // The old key is already cached, so decrypt will use it + + // 1. Collect all encrypted values (we need to decrypt then re-encrypt) + const allEncrypted: Array<{ + table: string; + id: number; + field: string; + value: string; + }> = []; + + const regs = await db.select().from(registries); + for (const reg of regs) { + if (reg.password && isEncrypted(reg.password)) { + allEncrypted.push({ table: 'registries', id: reg.id, field: 'password', value: reg.password }); + } + } + + const gitCreds = await db.select().from(gitCredentials); + for (const cred of gitCreds) { + if (cred.password && isEncrypted(cred.password)) { + allEncrypted.push({ table: 'gitCredentials', id: cred.id, field: 'password', value: cred.password }); + } + if (cred.sshPrivateKey && isEncrypted(cred.sshPrivateKey)) { + allEncrypted.push({ table: 'gitCredentials', id: cred.id, field: 'sshPrivateKey', value: cred.sshPrivateKey }); + } + if (cred.sshPassphrase && isEncrypted(cred.sshPassphrase)) { + allEncrypted.push({ table: 'gitCredentials', id: cred.id, field: 'sshPassphrase', value: cred.sshPassphrase }); + } + } + + const envs = await db.select().from(environments); + for (const env of envs) { + if (env.hawserToken && isEncrypted(env.hawserToken)) { + allEncrypted.push({ table: 'environments', id: env.id, field: 'hawserToken', value: env.hawserToken }); + } + if (env.tlsKey && isEncrypted(env.tlsKey)) { + allEncrypted.push({ table: 'environments', id: env.id, field: 'tlsKey', value: env.tlsKey }); + } + } + + const oidcConfigs = await db.select().from(oidcConfig); + for (const config of oidcConfigs) { + if (config.clientSecret && isEncrypted(config.clientSecret)) { + allEncrypted.push({ table: 'oidcConfig', id: config.id, field: 'clientSecret', value: config.clientSecret }); + } + } + + const ldapConfigs = await db.select().from(ldapConfig); + for (const config of ldapConfigs) { + if (config.bindPassword && isEncrypted(config.bindPassword)) { + allEncrypted.push({ table: 'ldapConfig', id: config.id, field: 'bindPassword', value: config.bindPassword }); + } + } + + const notifSettings = await db.select().from(notificationSettings); + for (const notif of notifSettings) { + if (notif.config) { + try { + const config = JSON.parse(notif.config); + if (config.smtpPassword && isEncrypted(config.smtpPassword)) { + allEncrypted.push({ table: 'notificationSettings', id: notif.id, field: 'config.smtpPassword', value: config.smtpPassword }); + } + } catch { + // Invalid JSON, skip + } + } + } + + const stackEnvVars = await db.select().from(stackEnvironmentVariables); + for (const envVar of stackEnvVars) { + if (envVar.isSecret && envVar.value && isEncrypted(envVar.value)) { + allEncrypted.push({ table: 'stackEnvironmentVariables', id: envVar.id, field: 'value', value: envVar.value }); + } + } + + // Decrypt all values with old key + const decryptedValues: Map = new Map(); + for (const item of allEncrypted) { + const decrypted = decrypt(item.value); + if (decrypted) { + decryptedValues.set(`${item.table}:${item.id}:${item.field}`, decrypted); + } + } + + // Switch to new key + cachedKey = pendingKeyRotation.newKey; + + // Re-encrypt and update all values + for (const item of allEncrypted) { + const decrypted = decryptedValues.get(`${item.table}:${item.id}:${item.field}`); + if (decrypted) { + const reEncrypted = encrypt(decrypted); + + // Update database based on table + if (item.table === 'registries') { + await db.update(registries).set({ [item.field]: reEncrypted }).where(eq(registries.id, item.id)); + } else if (item.table === 'gitCredentials') { + await db.update(gitCredentials).set({ [item.field]: reEncrypted }).where(eq(gitCredentials.id, item.id)); + } else if (item.table === 'environments') { + await db.update(environments).set({ [item.field]: reEncrypted }).where(eq(environments.id, item.id)); + } else if (item.table === 'oidcConfig') { + await db.update(oidcConfig).set({ [item.field]: reEncrypted }).where(eq(oidcConfig.id, item.id)); + } else if (item.table === 'ldapConfig') { + await db.update(ldapConfig).set({ [item.field]: reEncrypted }).where(eq(ldapConfig.id, item.id)); + } else if (item.table === 'notificationSettings' && item.field === 'config.smtpPassword') { + // Need to update the JSON field + const notif = notifSettings.find(n => n.id === item.id); + if (notif) { + const config = JSON.parse(notif.config); + config.smtpPassword = reEncrypted; + await db.update(notificationSettings).set({ config: JSON.stringify(config) }).where(eq(notificationSettings.id, item.id)); + } + } else if (item.table === 'stackEnvironmentVariables') { + await db.update(stackEnvironmentVariables).set({ value: reEncrypted }).where(eq(stackEnvironmentVariables.id, item.id)); + } + + migrated++; + } + } + + // Delete key file - env var is now the source of truth + if (existsSync(keyPath)) { + try { + unlinkSync(keyPath); + console.log('[Encryption] Deleted key file - now using ENCRYPTION_KEY from environment only'); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.warn(`[Encryption] Could not delete key file: ${msg}`); + } + } + + pendingKeyRotation = null; + + if (migrated > 0) { + console.log(`[Encryption] Re-encrypted ${migrated} credentials with new key`); + } else { + console.log('[Encryption] Key rotation complete (no credentials to re-encrypt)'); + } + return; + } + + const regs = await db.select().from(registries); + for (const reg of regs) { + if (reg.password && !isEncrypted(reg.password)) { + await db.update(registries) + .set({ password: encrypt(reg.password) }) + .where(eq(registries.id, reg.id)); + migrated++; + } + } + + const gitCreds = await db.select().from(gitCredentials); + for (const cred of gitCreds) { + const updates: Record = {}; + if (cred.password && !isEncrypted(cred.password)) { + updates.password = encrypt(cred.password); + migrated++; + } + if (cred.sshPrivateKey && !isEncrypted(cred.sshPrivateKey)) { + updates.sshPrivateKey = encrypt(cred.sshPrivateKey); + migrated++; + } + if (cred.sshPassphrase && !isEncrypted(cred.sshPassphrase)) { + updates.sshPassphrase = encrypt(cred.sshPassphrase); + migrated++; + } + if (Object.keys(updates).length > 0) { + await db.update(gitCredentials).set(updates).where(eq(gitCredentials.id, cred.id)); + } + } + + const envs = await db.select().from(environments); + for (const env of envs) { + const updates: Record = {}; + if (env.hawserToken && !isEncrypted(env.hawserToken)) { + updates.hawserToken = encrypt(env.hawserToken); + migrated++; + } + if (env.tlsKey && !isEncrypted(env.tlsKey)) { + updates.tlsKey = encrypt(env.tlsKey); + migrated++; + } + if (Object.keys(updates).length > 0) { + await db.update(environments).set(updates).where(eq(environments.id, env.id)); + } + } + + const oidcConfigs = await db.select().from(oidcConfig); + for (const config of oidcConfigs) { + if (config.clientSecret && !isEncrypted(config.clientSecret)) { + await db.update(oidcConfig) + .set({ clientSecret: encrypt(config.clientSecret) }) + .where(eq(oidcConfig.id, config.id)); + migrated++; + } + } + + const ldapConfigs = await db.select().from(ldapConfig); + for (const config of ldapConfigs) { + if (config.bindPassword && !isEncrypted(config.bindPassword)) { + await db.update(ldapConfig) + .set({ bindPassword: encrypt(config.bindPassword) }) + .where(eq(ldapConfig.id, config.id)); + migrated++; + } + } + + const notifSettings = await db.select().from(notificationSettings); + for (const notif of notifSettings) { + if (notif.config) { + try { + const config = JSON.parse(notif.config); + if (config.smtpPassword && !isEncrypted(config.smtpPassword)) { + config.smtpPassword = encrypt(config.smtpPassword); + await db.update(notificationSettings) + .set({ config: JSON.stringify(config) }) + .where(eq(notificationSettings.id, notif.id)); + migrated++; + } + } catch { + // Invalid JSON, skip + } + } + } + + const stackEnvVars = await db.select().from(stackEnvironmentVariables); + for (const envVar of stackEnvVars) { + if (envVar.isSecret && envVar.value && !isEncrypted(envVar.value)) { + await db.update(stackEnvironmentVariables) + .set({ value: encrypt(envVar.value) }) + .where(eq(stackEnvironmentVariables.id, envVar.id)); + migrated++; + } + } + + if (migrated > 0) { + console.log(`[Encryption] Migrated ${migrated} credentials to encrypted storage`); + } +} diff --git a/src/lib/server/git.ts b/src/lib/server/git.ts index 407221b..9f4316e 100644 --- a/src/lib/server/git.ts +++ b/src/lib/server/git.ts @@ -137,6 +137,45 @@ async function execGit(args: string[], cwd: string, env: GitEnv): Promise<{ stdo } } +/** + * Get list of files that changed between two commits in a specific directory. + * Returns array of changed file paths (relative to repo root). + */ +async function getChangedFilesInDir( + repoPath: string, + previousCommit: string, + newCommit: string, + dirPath: string, + env: GitEnv +): Promise<{ changed: boolean; files: string[]; error?: string }> { + if (!previousCommit) { + // No previous commit means this is a new clone - always deploy + return { changed: true, files: ['(new clone - all files)'] }; + } + + // Use git diff --name-only to get all changed files in the directory + // The trailing slash ensures we only match files IN that directory (and subdirs) + const dirPattern = dirPath.endsWith('/') ? dirPath : `${dirPath}/`; + const result = await execGit( + ['diff', '--name-only', previousCommit, newCommit, '--', dirPattern], + repoPath, + env + ); + + // If the command fails (e.g., previousCommit no longer exists after force push), + // assume files changed to be safe + if (result.code !== 0) { + return { changed: true, files: ['(diff failed - assuming changed)'], error: result.stderr }; + } + + // Parse changed files + const changedFiles = result.stdout.trim() + .split('\n') + .filter(f => f.length > 0); + + return { changed: changedFiles.length > 0, files: changedFiles }; +} + export interface SyncResult { success: boolean; commit?: string; @@ -148,6 +187,7 @@ export interface SyncResult { envFileName?: string; // Filename of env file relative to composeDir (e.g., ".env" or "../.env") error?: string; updated?: boolean; + changedFiles?: string[]; // List of files that changed (for logging/debugging) } export interface TestResult { @@ -497,7 +537,8 @@ export function deleteRepositoryFiles(repoId: number): void { rmSync(repoPath, { recursive: true, force: true }); } } catch (error) { - console.error('Failed to delete repository files:', error); + const errorMsg = error instanceof Error ? error.message : String(error); + console.error('[Git] Failed to delete repository files:', errorMsg); } } @@ -600,8 +641,45 @@ export async function syncGitStack(stackId: number): Promise { // Check if commit changed const newCommitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env); const newCommit = newCommitResult.stdout.trim(); - updated = previousCommit !== newCommit; - console.log(`${logPrefix} Previous commit: ${previousCommit || '(none)'}, new commit: ${newCommit.substring(0, 7)}, updated: ${updated}`); + const commitChanged = previousCommit !== newCommit; + console.log(`${logPrefix} Previous commit: ${previousCommit || '(none)'}, new commit: ${newCommit.substring(0, 7)}, commit changed: ${commitChanged}`); + + // Check if any files in the compose file's directory have changed + // This catches changes to the compose file, env files, and any other referenced files + // (e.g., config files, scripts, additional env files) + let changedFiles: string[] = []; + if (commitChanged) { + // Get the directory containing the compose file (relative to repo root) + const composeDirRelative = dirname(gitStack.composePath); + console.log(`${logPrefix} Checking for changes in directory: ${composeDirRelative || '(root)'}`); + + const diffResult = await getChangedFilesInDir( + repoPath, + previousCommit, + newCommit, + composeDirRelative || '.', + env + ); + + updated = diffResult.changed; + changedFiles = diffResult.files; + + if (diffResult.error) { + console.log(`${logPrefix} Diff error: ${diffResult.error}`); + } + + if (changedFiles.length > 0) { + console.log(`${logPrefix} Changed files (${changedFiles.length}):`); + for (const file of changedFiles) { + console.log(`${logPrefix} - ${file}`); + } + } else { + console.log(`${logPrefix} No files changed in stack directory`); + } + } else { + updated = false; + console.log(`${logPrefix} No commit change, skipping file diff`); + } // Get current commit hash const commitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env); @@ -671,6 +749,7 @@ export async function syncGitStack(stackId: number): Promise { console.log(`${logPrefix} ----------------------------------------`); console.log(`${logPrefix} Success: true`); console.log(`${logPrefix} Updated:`, updated); + console.log(`${logPrefix} Changed files:`, changedFiles.length > 0 ? changedFiles.join(', ') : '(none)'); console.log(`${logPrefix} Commit:`, currentCommit); console.log(`${logPrefix} Env file vars count:`, envFileVars ? Object.keys(envFileVars).length : 0); @@ -682,7 +761,8 @@ export async function syncGitStack(stackId: number): Promise { composeFileName, envFileVars, envFileName, - updated + updated, + changedFiles }; } catch (error: any) { cleanupSshKey(credential); @@ -850,7 +930,8 @@ export function deleteGitStackFiles(stackId: number): void { rmSync(repoPath, { recursive: true, force: true }); } } catch (error) { - console.error('Failed to delete git stack files:', error); + const errorMsg = error instanceof Error ? error.message : String(error); + console.error('[Git] Failed to delete git stack files:', errorMsg); } } @@ -930,7 +1011,23 @@ export async function deployGitStackWithProgress( // Check if commit changed const newCommitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env); const newCommit = newCommitResult.stdout.trim(); - updated = previousCommit !== newCommit; + const commitChanged = previousCommit !== newCommit; + + // Check if any files in the compose file's directory have changed + // (for consistency with syncGitStack, though this function always deploys) + if (commitChanged) { + const composeDir = dirname(gitStack.composePath); + const diffResult = await getChangedFilesInDir( + repoPath, + previousCommit, + newCommit, + composeDir || '.', + env + ); + updated = diffResult.changed; + } else { + updated = false; + } // Get current commit hash const commitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env); diff --git a/src/lib/server/hawser.ts b/src/lib/server/hawser.ts index d113cf8..e9b8e6a 100644 --- a/src/lib/server/hawser.ts +++ b/src/lib/server/hawser.ts @@ -184,7 +184,8 @@ export async function handleEdgeContainerEvent( type: notificationType as 'success' | 'error' | 'warning' | 'info' }, event.image); } catch (error) { - console.error('[Hawser] Error handling container event:', error); + const errorMsg = error instanceof Error ? error.message : String(error); + console.error('[Hawser] Error handling container event:', errorMsg); } } @@ -226,7 +227,8 @@ export async function handleEdgeMetrics( environmentId ); } catch (error) { - console.error('[Hawser] Error saving metrics:', error); + const errorMsg = error instanceof Error ? error.message : String(error); + console.error('[Hawser] Error saving metrics:', errorMsg); } } @@ -365,7 +367,8 @@ export function closeEdgeConnection(environmentId: number): void { try { connection.ws.close(1000, 'Environment deleted'); } catch (e) { - console.error(`[Hawser] Error closing WebSocket for environment ${environmentId}:`, e); + const errorMsg = e instanceof Error ? e.message : String(e); + console.error(`[Hawser] Error closing WebSocket for environment ${environmentId}:`, errorMsg); } edgeConnections.delete(environmentId); @@ -578,7 +581,8 @@ export async function sendEdgeRequest( try { connection.ws.send(messageStr); } catch (sendError) { - console.error(`[Hawser Edge] Error sending message:`, sendError); + const errorMsg = sendError instanceof Error ? sendError.message : String(sendError); + console.error(`[Hawser Edge] Error sending message:`, errorMsg); connection.pendingRequests.delete(requestId); if (streaming) { connection.pendingStreamRequests.delete(requestId); @@ -650,9 +654,10 @@ export function sendEdgeStreamRequest( try { connection.ws.send(messageStr); } catch (sendError) { - console.error(`[Hawser Edge] Error sending streaming message:`, sendError); + const errorMsg = sendError instanceof Error ? sendError.message : String(sendError); + console.error(`[Hawser Edge] Error sending streaming message:`, errorMsg); connection.pendingStreamRequests.delete(requestId); - callbacks.onError(sendError instanceof Error ? sendError.message : String(sendError)); + callbacks.onError(errorMsg); return { requestId: '', cancel: () => {} }; } } diff --git a/src/lib/server/license.ts b/src/lib/server/license.ts index 4ba3b67..2eef542 100644 --- a/src/lib/server/license.ts +++ b/src/lib/server/license.ts @@ -248,6 +248,7 @@ export async function checkLicenseExpiry(): Promise { lastLicenseExpiryNotification = Date.now(); } } catch (error) { - console.error('[License] Failed to check license expiry:', error); + const errorMsg = error instanceof Error ? error.message : String(error); + console.error('[License] Failed to check license expiry:', errorMsg); } } diff --git a/src/lib/server/notifications.ts b/src/lib/server/notifications.ts index c83c545..734b7d5 100644 --- a/src/lib/server/notifications.ts +++ b/src/lib/server/notifications.ts @@ -9,6 +9,18 @@ import { type NotificationEventType } from './db'; +// Escape special characters for Telegram Markdown +function escapeTelegramMarkdown(text: string): string { + // Escape characters that have special meaning in Telegram Markdown + return text + .replace(/\\/g, '\\\\') // Escape backslashes first + .replace(/_/g, '\\_') // Underscore (italic) + .replace(/\*/g, '\\*') // Asterisk (bold) + .replace(/\[/g, '\\[') // Opening bracket (link) + .replace(/\]/g, '\\]') // Closing bracket (link) + .replace(/`/g, '\\`'); // Backtick (code) +} + export interface NotificationPayload { title: string; message: string; @@ -57,7 +69,8 @@ async function sendSmtpNotification(config: SmtpConfig, payload: NotificationPay return true; } catch (error) { - console.error('[Notifications] SMTP send failed:', error); + const errorMsg = error instanceof Error ? error.message : String(error); + console.error('[Notifications] SMTP send failed:', errorMsg); return false; } } @@ -71,7 +84,8 @@ async function sendAppriseNotification(config: AppriseConfig, payload: Notificat const sent = await sendToAppriseUrl(url, payload); if (!sent) success = false; } catch (error) { - console.error(`[Notifications] Failed to send to ${url}:`, error); + const errorMsg = error instanceof Error ? error.message : String(error); + console.error(`[Notifications] Failed to send to ${url}:`, errorMsg); success = false; } } @@ -117,7 +131,8 @@ async function sendToAppriseUrl(url: string, payload: NotificationPayload): Prom return false; } } catch (error) { - console.error('[Notifications] Failed to parse Apprise URL:', error); + const errorMsg = error instanceof Error ? error.message : String(error); + console.error('[Notifications] Failed to parse Apprise URL:', errorMsg); return false; } } @@ -181,14 +196,18 @@ async function sendTelegram(appriseUrl: string, payload: NotificationPayload): P const [, botToken, chatId] = match; const url = `https://api.telegram.org/bot${botToken}/sendMessage`; - const envTag = payload.environmentName ? ` \\[${payload.environmentName}\\]` : ''; + // Escape markdown special characters in title and message + const escapedTitle = escapeTelegramMarkdown(payload.title); + const escapedMessage = escapeTelegramMarkdown(payload.message); + const envTag = payload.environmentName ? ` \\[${escapeTelegramMarkdown(payload.environmentName)}\\]` : ''; + try { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chat_id: chatId, - text: `*${payload.title}*${envTag}\n${payload.message}`, + text: `*${escapedTitle}*${envTag}\n${escapedMessage}`, parse_mode: 'Markdown' }) }); @@ -200,7 +219,8 @@ async function sendTelegram(appriseUrl: string, payload: NotificationPayload): P return response.ok; } catch (error) { - console.error('[Notifications] Telegram send failed:', error); + const errorMsg = error instanceof Error ? error.message : String(error); + console.error('[Notifications] Telegram send failed:', errorMsg); return false; } } @@ -421,7 +441,8 @@ export async function sendEnvironmentNotification( if (success) sent++; else allSuccess = false; } catch (error) { - console.error(`[Notifications] Failed to send to channel ${notif.channelName}:`, error); + const errorMsg = error instanceof Error ? error.message : String(error); + console.error(`[Notifications] Failed to send to channel ${notif.channelName}:`, errorMsg); allSuccess = false; } } @@ -493,7 +514,8 @@ export async function sendEventNotification( if (success) sent++; else allSuccess = false; } catch (error) { - console.error(`[Notifications] Failed to send to channel ${channel.channel_name}:`, error); + const errorMsg = error instanceof Error ? error.message : String(error); + console.error(`[Notifications] Failed to send to channel ${channel.channel_name}:`, errorMsg); allSuccess = false; } } diff --git a/src/lib/server/scanner.ts b/src/lib/server/scanner.ts index 4d34470..a8c9e28 100644 --- a/src/lib/server/scanner.ts +++ b/src/lib/server/scanner.ts @@ -232,7 +232,8 @@ async function ensureScannerImage( await pullImage(scannerImage, undefined, envId); return true; } catch (error) { - console.error(`Failed to pull scanner image ${scannerImage}:`, error); + const errorMsg = error instanceof Error ? error.message : String(error); + console.error(`[Scanner] Failed to pull image ${scannerImage}:`, errorMsg); return false; } } @@ -281,8 +282,11 @@ function parseGrypeOutput(output: string): { vulnerabilities: Vulnerability[]; s } } } catch (error) { - console.error('[Grype] Failed to parse output:', error); - console.error('[Grype] Output was:', output.slice(0, 500)); + const errorMsg = error instanceof Error ? error.message : String(error); + console.error('[Grype] Failed to parse output:', errorMsg); + if (output.length > 0) { + console.error('[Grype] Output preview:', output.slice(0, 200)); + } // Check if output looks like an error message from grype const firstLine = output.split('\n')[0].trim(); if (firstLine && !firstLine.startsWith('{')) { @@ -337,8 +341,11 @@ function parseTrivyOutput(output: string): { vulnerabilities: Vulnerability[]; s } } } catch (error) { - console.error('[Trivy] Failed to parse output:', error); - console.error('[Trivy] Output was:', output.slice(0, 500)); + const errorMsg = error instanceof Error ? error.message : String(error); + console.error('[Trivy] Failed to parse output:', errorMsg); + if (output.length > 0) { + console.error('[Trivy] Output preview:', output.slice(0, 200)); + } // Check if output looks like an error message from trivy const firstLine = output.split('\n')[0].trim(); if (firstLine && !firstLine.startsWith('{')) { @@ -667,7 +674,8 @@ export async function scanImage( const result = await scanWithGrype(imageName, envId, onProgress); results.push(result); } catch (error) { - console.error('Grype scan failed:', error); + const errorMsg = error instanceof Error ? error.message : String(error); + console.error('[Grype] Scan failed:', errorMsg); errors.push(error instanceof Error ? error : new Error(String(error))); if (scannerType === 'grype') throw error; } @@ -678,7 +686,8 @@ export async function scanImage( const result = await scanWithTrivy(imageName, envId, onProgress); results.push(result); } catch (error) { - console.error('Trivy scan failed:', error); + const errorMsg = error instanceof Error ? error.message : String(error); + console.error('[Trivy] Scan failed:', errorMsg); errors.push(error instanceof Error ? error : new Error(String(error))); if (scannerType === 'trivy') throw error; } @@ -703,7 +712,8 @@ export async function scanImage( // Send notifications (async, don't block return) sendVulnerabilityNotifications(imageName, combinedSummary, envId).catch(err => { - console.error('[Scanner] Failed to send vulnerability notifications:', err); + const errorMsg = err instanceof Error ? err.message : String(err); + console.error('[Scanner] Failed to send vulnerability notifications:', errorMsg); }); } @@ -766,7 +776,8 @@ async function getScannerVersion( return version; } catch (error) { - console.error(`Failed to get ${scannerType} version:`, error); + const errorMsg = error instanceof Error ? error.message : String(error); + console.error(`[Scanner] Failed to get ${scannerType} version:`, errorMsg); return null; } } @@ -815,11 +826,13 @@ export async function checkScannerUpdates(envId?: number): Promise<{ result[scanner].hasUpdate = false; } } catch (error) { - console.error(`Failed to check updates for ${scanner}:`, error); + const errorMsg = error instanceof Error ? error.message : String(error); + console.error(`[Scanner] Failed to check updates for ${scanner}:`, errorMsg); } } } catch (error) { - console.error('Failed to check scanner updates:', error); + const errorMsg = error instanceof Error ? error.message : String(error); + console.error('[Scanner] Failed to check scanner updates:', errorMsg); } return result; @@ -838,6 +851,7 @@ export async function cleanupScannerVolumes(envId?: number): Promise { } } } catch (error) { - console.error('Failed to cleanup scanner volumes:', error); + const errorMsg = error instanceof Error ? error.message : String(error); + console.error('[Scanner] Failed to cleanup scanner volumes:', errorMsg); } } diff --git a/src/lib/server/scheduler/index.ts b/src/lib/server/scheduler/index.ts index 0fda3e5..8823205 100644 --- a/src/lib/server/scheduler/index.ts +++ b/src/lib/server/scheduler/index.ts @@ -28,6 +28,7 @@ import { getEnvironmentTimezone, getDefaultTimezone } from '../db'; +import { db, gitStacks, eq } from '../db/drizzle.js'; import { cleanupStaleVolumeHelpers, cleanupExpiredVolumeHelpers @@ -57,6 +58,30 @@ let volumeHelperCleanupJob: Cron | null = null; // Scheduler state let isRunning = false; +/** + * Clean up stale 'syncing' states from git stacks. + * Called on startup to recover from crashes during sync operations. + */ +async function cleanupStaleSyncStates(): Promise { + const staleStacks = await db.select().from(gitStacks).where(eq(gitStacks.syncStatus, 'syncing')); + + if (staleStacks.length === 0) { + return; + } + + console.log(`[Scheduler] Recovering ${staleStacks.length} git stack(s) from stale syncing state`); + + for (const stack of staleStacks) { + await db.update(gitStacks).set({ + syncStatus: 'pending', + syncError: 'Recovered from interrupted sync on startup', + updatedAt: new Date().toISOString() + }).where(eq(gitStacks.id, stack.id)); + + console.log(`[Scheduler] Reset git stack "${stack.stackName}" (ID: ${stack.id}) to pending`); + } +} + /** * Start the unified scheduler service. * Registers all schedules with croner for automatic execution. @@ -70,6 +95,9 @@ export async function startScheduler(): Promise { console.log('[Scheduler] Starting scheduler service...'); isRunning = true; + // Clean up stale sync states from previous crashed processes + await cleanupStaleSyncStates(); + // Get cron expressions and default timezone from database const scheduleCleanupCron = await getScheduleCleanupCron(); const eventCleanupCron = await getEventCleanupCron(); @@ -102,7 +130,8 @@ export async function startScheduler(): Promise { // Run volume helper cleanup immediately on startup to clean up stale containers runVolumeHelperCleanupJob('startup', volumeCleanupFns).catch(err => { - console.error('[Scheduler] Error during startup volume helper cleanup:', err); + const errorMsg = err instanceof Error ? err.message : String(err); + console.error('[Scheduler] Error during startup volume helper cleanup:', errorMsg); }); console.log(`[Scheduler] System schedule cleanup: ${scheduleCleanupCron} [${defaultTimezone}]`); @@ -177,7 +206,8 @@ export async function refreshAllSchedules(): Promise { } } } catch (error) { - console.error('[Scheduler] Error loading container schedules:', error); + const errorMsg = error instanceof Error ? error.message : String(error); + console.error('[Scheduler] Error loading container schedules:', errorMsg); } // Register git stack auto-sync schedules @@ -194,7 +224,8 @@ export async function refreshAllSchedules(): Promise { } } } catch (error) { - console.error('[Scheduler] Error loading git stack schedules:', error); + const errorMsg = error instanceof Error ? error.message : String(error); + console.error('[Scheduler] Error loading git stack schedules:', errorMsg); } // Register environment update check schedules @@ -212,7 +243,8 @@ export async function refreshAllSchedules(): Promise { } } } catch (error) { - console.error('[Scheduler] Error loading env update check schedules:', error); + const errorMsg = error instanceof Error ? error.message : String(error); + console.error('[Scheduler] Error loading env update check schedules:', errorMsg); } console.log(`[Scheduler] Registered ${containerCount} container schedules, ${gitStackCount} git stack schedules, ${envUpdateCheckCount} env update check schedules`); @@ -337,7 +369,8 @@ export async function refreshSchedulesForEnvironment(environmentId: number): Pro } } } catch (error) { - console.error('[Scheduler] Error refreshing container schedules:', error); + const errorMsg = error instanceof Error ? error.message : String(error); + console.error('[Scheduler] Error refreshing container schedules:', errorMsg); } // Re-register git stack auto-sync schedules for this environment @@ -354,7 +387,8 @@ export async function refreshSchedulesForEnvironment(environmentId: number): Pro } } } catch (error) { - console.error('[Scheduler] Error refreshing git stack schedules:', error); + const errorMsg = error instanceof Error ? error.message : String(error); + console.error('[Scheduler] Error refreshing git stack schedules:', errorMsg); } // Re-register environment update check schedule for this environment @@ -369,7 +403,8 @@ export async function refreshSchedulesForEnvironment(environmentId: number): Pro if (registered) refreshedCount++; } } catch (error) { - console.error('[Scheduler] Error refreshing env update check schedule:', error); + const errorMsg = error instanceof Error ? error.message : String(error); + console.error('[Scheduler] Error refreshing env update check schedule:', errorMsg); } console.log(`[Scheduler] Refreshed ${refreshedCount} schedules for environment ${environmentId}`); diff --git a/src/lib/server/scheduler/tasks/container-update.ts b/src/lib/server/scheduler/tasks/container-update.ts index c0f6d48..2442364 100644 --- a/src/lib/server/scheduler/tasks/container-update.ts +++ b/src/lib/server/scheduler/tasks/container-update.ts @@ -2,6 +2,13 @@ * Container Auto-Update Task * * Handles automatic container updates with vulnerability scanning. + * + * For containers that are part of a Docker Compose stack, updates use + * `docker compose up -d` to preserve ALL configuration from the compose file + * (network aliases, static IPs, health checks, resource limits, etc.). + * + * For standalone containers, updates use container recreation with comprehensive + * settings preservation. */ import type { ScheduleTrigger, VulnerabilityCriteria } from '../../db'; @@ -21,17 +28,20 @@ import { inspectContainer, createContainer, stopContainer, + startContainer, removeContainer, checkImageUpdateAvailable, getTempImageTag, isDigestBasedImage, getImageIdByTag, removeTempImage, - tagImage + tagImage, + connectContainerToNetwork } from '../../docker'; import { getScannerSettings, scanImage, type ScanResult, type VulnerabilitySeverity } from '../../scanner'; import { sendEventNotification } from '../../notifications'; import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils'; +import { startStack, getStackComposeFile } from '../../stacks'; /** * Execute a container auto-update. @@ -120,6 +130,18 @@ export async function runContainerUpdate( log(`Container is using image: ${imageNameFromConfig}`); log(`Current image ID: ${currentImageId?.substring(0, 19)}`); + // Detect if container is part of a Docker Compose stack + const containerLabels = inspectData.Config?.Labels || {}; + const composeProject = containerLabels['com.docker.compose.project']; + const composeService = containerLabels['com.docker.compose.service']; + const isStackContainer = !!composeProject; + + if (isStackContainer) { + log(`Container is part of compose stack: ${composeProject} (service: ${composeService})`); + } else { + log(`Container is standalone (not part of a compose stack)`); + } + // Get scanner and schedule settings early to determine scan strategy const [scannerSettings, updateSetting] = await Promise.all([ getScannerSettings(envId), @@ -431,8 +453,30 @@ export async function runContainerUpdate( } } - log(`Proceeding with container recreation...`); - const success = await recreateContainer(containerName, envId, log); + // ============================================================================= + // Update the container based on type + // ============================================================================= + let success = false; + + if (isStackContainer) { + log(`Updating via docker compose for stack: ${composeProject}`); + + // Try stack-based update first + const stackSuccess = await updateStackContainer(composeProject!, composeService!, envId, log); + + if (stackSuccess) { + success = true; + } else { + // Fallback: Stack is external (not managed by Dockhand), use container recreation + log(`Fallback: Recreating container directly (stack "${composeProject}" not managed by Dockhand)`); + log(`WARNING: Some compose-specific settings may not be preserved`); + log(`Consider importing this stack into Dockhand for full configuration preservation`); + success = await recreateContainer(containerName, envId, log); + } + } else { + log(`Updating standalone container via recreation...`); + success = await recreateContainer(containerName, envId, log); + } if (success) { await updateAutoUpdateLastUpdated(containerName, envId); @@ -504,10 +548,18 @@ export async function runContainerUpdate( } // ============================================================================= -// HELPER FUNCTIONS +// EXPORTED HELPER FUNCTIONS (reused by batch-update-stream and batch-update) // ============================================================================= -async function recreateContainer( +/** + * Recreate a standalone container with comprehensive settings preservation. + * Extracts and preserves 50+ container settings from the original container. + * + * Note: For containers that are part of a Docker Compose stack, use + * updateStackContainer() instead, which uses `docker compose up -d` to + * preserve ALL settings including network aliases, static IPs, etc. + */ +export async function recreateContainer( containerName: string, envId?: number, log?: (msg: string) => void @@ -529,6 +581,7 @@ async function recreateContainer( const hostConfig = inspectData.HostConfig; log?.(`Recreating container: ${containerName} (was running: ${wasRunning})`); + log?.(`Preserving all container settings...`); // Stop container if running if (wasRunning) { @@ -540,40 +593,438 @@ async function recreateContainer( log?.('Removing old container...'); await removeContainer(container.id, true, envId); - // Prepare port bindings - const ports: { [key: string]: { HostPort: string } } = {}; + // ============================================================================= + // Extract ALL settings from the original container + // ============================================================================= + + // 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) { - ports[containerPort] = { HostPort: (bindings as any[])[0].HostPort || '' }; + 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; + } } } } - // Create new container - log?.('Creating new container...'); + // Volume bindings - preserve ALL volumes including anonymous volumes + // hostConfig.Binds contains named volumes and bind mounts in "source:dest" format + // inspectData.Mounts contains ALL mounts including anonymous volumes with their generated names + 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); + // Track the destination path to avoid duplicates + const parts = bind.split(':'); + if (parts.length >= 2) { + mountedPaths.add(parts[1].split(':')[0]); // Handle "src:dest:ro" format + } + } + } + + // Then, add anonymous volumes from Mounts that aren't already in Binds + // These have Type: "volume" and a generated Name (hash), but no entry in Binds + const mounts = inspectData.Mounts || []; + for (const mount of mounts) { + if (mount.Type === 'volume' && mount.Name && mount.Destination) { + // Skip if this destination is already covered by Binds + if (!mountedPaths.has(mount.Destination)) { + // Format: "volumeName:destination" or "volumeName:destination:ro" + const bindStr = mount.RW === false + ? `${mount.Name}:${mount.Destination}:ro` + : `${mount.Name}:${mount.Destination}`; + volumeBinds.push(bindStr); + log?.(`Preserving anonymous volume: ${mount.Name} -> ${mount.Destination}`); + } + } + } + + // Healthcheck configuration + let healthcheck: any = undefined; + if (config.Healthcheck && config.Healthcheck.Test && config.Healthcheck.Test.length > 0) { + // Skip if healthcheck is disabled (NONE) + 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 connections with aliases and static IPs + const networkSettings = inspectData.NetworkSettings?.Networks || {}; + const primaryNetwork = hostConfig.NetworkMode || 'bridge'; + + // Build network info for reconnection (including aliases, IPs, and gateway priority) + interface NetworkInfo { + name: string; + aliases: string[]; + ipv4Address: string | undefined; + ipv6Address: string | undefined; + gwPriority: number | undefined; + } + + // Extract primary network aliases, static IP, and gateway priority (for createContainer) + let primaryNetworkAliases: string[] | undefined; + let primaryNetworkIpv4: string | undefined; + let primaryNetworkIpv6: string | undefined; + let primaryNetworkMacAddress: string | undefined; + let primaryNetworkGwPriority: number | undefined; + + const additionalNetworks: NetworkInfo[] = []; + for (const [netName, netConfig] of Object.entries(networkSettings)) { + const netConf = netConfig as any; + + // Check if this is the primary network + const isPrimary = netName === primaryNetwork || + (primaryNetwork === 'bridge' && (netName === 'bridge' || netName === 'default')); + + if (isPrimary) { + // Extract primary network's aliases and static IP + // Filter out auto-generated aliases (container name and ID prefix) + // Note: Docker Compose stores aliases in both Aliases and DNSNames, + // but after container recreation Aliases may be null while DNSNames has the values + const allAliases = (netConf.Aliases?.length > 0 ? netConf.Aliases : netConf.DNSNames) || []; + const shortContainerId = container.id.substring(0, 12); + primaryNetworkAliases = allAliases.filter((a: string) => + a !== containerName && + a !== container.id && + a !== shortContainerId + ); + if (!primaryNetworkAliases || primaryNetworkAliases.length === 0) { + primaryNetworkAliases = undefined; + } + + // Extract static IP from IPAMConfig (user-configured) - don't use auto-assigned IPAddress + primaryNetworkIpv4 = netConf.IPAMConfig?.IPv4Address || undefined; + primaryNetworkIpv6 = netConf.IPAMConfig?.IPv6Address || undefined; + + // Extract MAC address (only if explicitly set, not auto-generated) + // Auto-generated MACs start with 02:42, so we preserve all MACs + primaryNetworkMacAddress = netConf.MacAddress || undefined; + + // Extract gateway priority (Docker Engine 28+) + // GwPriority determines which network provides the default gateway + primaryNetworkGwPriority = netConf.GwPriority !== undefined && netConf.GwPriority !== 0 + ? netConf.GwPriority : undefined; + + if (primaryNetworkAliases?.length) { + log?.(`Primary network aliases: ${primaryNetworkAliases.join(', ')}`); + } + if (primaryNetworkIpv4) { + log?.(`Primary network static IPv4: ${primaryNetworkIpv4}`); + } + if (primaryNetworkMacAddress) { + log?.(`Primary network MAC address: ${primaryNetworkMacAddress}`); + } + if (primaryNetworkGwPriority !== undefined) { + log?.(`Primary network gateway priority: ${primaryNetworkGwPriority}`); + } + } else { + // Secondary network - add to reconnection list + // Use DNSNames as fallback for aliases (see comment above for primary network) + additionalNetworks.push({ + name: netName, + aliases: (netConf.Aliases?.length > 0 ? netConf.Aliases : netConf.DNSNames) || [], + ipv4Address: netConf.IPAMConfig?.IPv4Address || undefined, + ipv6Address: netConf.IPAMConfig?.IPv6Address || undefined, + gwPriority: netConf.GwPriority !== undefined && netConf.GwPriority !== 0 + ? netConf.GwPriority : undefined + }); + } + } + + if (additionalNetworks.length > 0) { + log?.(`Will reconnect to ${additionalNetworks.length} additional network(s): ${additionalNetworks.map(n => n.name).join(', ')}`); + } + + // Log extra hosts if present + if (hostConfig.ExtraHosts?.length > 0) { + log?.(`Extra hosts: ${hostConfig.ExtraHosts.join(', ')}`); + } + + // Log device requests if present (GPU, etc.) + if (hostConfig.DeviceRequests?.length > 0) { + for (const dr of hostConfig.DeviceRequests) { + const caps = dr.Capabilities?.flat().join(',') || 'none'; + log?.(`Device request: driver=${dr.Driver || 'default'}, count=${dr.Count}, capabilities=[${caps}]`); + } + } + + // Create new container with ALL preserved settings + log?.('Creating new container with preserved settings...'); const newContainer = await createContainer({ name: containerName, image: config.Image, - ports, - volumeBinds: hostConfig.Binds || [], + + // Command and entrypoint + cmd: config.Cmd || undefined, + entrypoint: config.Entrypoint || undefined, + workingDir: config.WorkingDir || undefined, + + // Environment and labels env: config.Env || [], labels: config.Labels || {}, - cmd: config.Cmd || undefined, + + // Port mappings + ports: Object.keys(ports).length > 0 ? ports : undefined, + + // Volume bindings (includes both named and anonymous volumes) + volumeBinds: volumeBinds.length > 0 ? volumeBinds : undefined, + + // Restart policy restartPolicy: hostConfig.RestartPolicy?.Name || 'no', - networkMode: hostConfig.NetworkMode || undefined + restartMaxRetries: hostConfig.RestartPolicy?.MaximumRetryCount, + + // Network mode and network-specific settings + networkMode: hostConfig.NetworkMode || undefined, + networkAliases: primaryNetworkAliases, + networkIpv4Address: primaryNetworkIpv4, + networkIpv6Address: primaryNetworkIpv6, + networkGwPriority: primaryNetworkGwPriority, + + // User and hostname + user: config.User || undefined, + hostname: config.Hostname || 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 (from primary network settings) + macAddress: primaryNetworkMacAddress, + + // Extra hosts (/etc/hosts entries) + extraHosts: hostConfig.ExtraHosts?.length > 0 ? hostConfig.ExtraHosts : undefined, + + // Device requests (GPU access, etc.) + 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, + + // Container runtime (critical for GPU containers using nvidia runtime) + runtime: hostConfig.Runtime && hostConfig.Runtime !== 'runc' ? hostConfig.Runtime : undefined, + + // Read-only root filesystem (security hardening) + readonlyRootfs: hostConfig.ReadonlyRootfs === true ? true : undefined, + + // CPU pinning + cpusetCpus: hostConfig.CpusetCpus || undefined, + + // NUMA memory nodes + cpusetMems: hostConfig.CpusetMems || undefined, + + // Additional groups + groupAdd: hostConfig.GroupAdd?.length > 0 ? hostConfig.GroupAdd : undefined, + + // Memory swappiness (0-100) + memorySwappiness: hostConfig.MemorySwappiness !== null ? hostConfig.MemorySwappiness : undefined, + + // User namespace mode + usernsMode: hostConfig.UsernsMode || undefined, + + // Domain name + domainname: config.Domainname || undefined }, envId); + // Reconnect to additional networks with aliases, static IPs, and gateway priority (before starting) + if (additionalNetworks.length > 0) { + log?.(`Reconnecting to ${additionalNetworks.length} additional network(s)...`); + for (const netInfo of additionalNetworks) { + try { + await connectContainerToNetwork(netInfo.name, newContainer.id, envId, { + aliases: netInfo.aliases.length > 0 ? netInfo.aliases : undefined, + ipv4Address: netInfo.ipv4Address, + ipv6Address: netInfo.ipv6Address, + gwPriority: netInfo.gwPriority + }); + log?.(` Connected to: ${netInfo.name}`); + if (netInfo.aliases.length > 0) { + log?.(` Aliases: ${netInfo.aliases.join(', ')}`); + } + if (netInfo.ipv4Address) { + log?.(` Static IPv4: ${netInfo.ipv4Address}`); + } + if (netInfo.gwPriority !== undefined) { + log?.(` Gateway priority: ${netInfo.gwPriority}`); + } + } catch (netError: any) { + log?.(` Warning: Failed to connect to network "${netInfo.name}": ${netError.message}`); + // Don't fail the entire update for network connection issues + } + } + } + // Start if was running if (wasRunning) { log?.('Starting new container...'); await newContainer.start(); } - log?.('Container recreated successfully'); + log?.('Container recreated successfully with all settings preserved'); return true; } catch (error: any) { log?.(`Failed to recreate container: ${error.message}`); return false; } } + +/** + * Update a container that is part of a Docker Compose stack. + * Uses `docker compose up -d` which preserves ALL configuration from the compose file. + * + * @param stackName - The compose project name (com.docker.compose.project label) + * @param serviceName - The service name within the stack (com.docker.compose.service label) + * @param envId - Optional environment ID + * @param log - Optional logging function + * @returns true if update succeeded, false if stack not found (use fallback) + */ +export async function updateStackContainer( + stackName: string, + serviceName: string, + envId?: number, + log?: (msg: string) => void +): Promise { + try { + log?.(`Looking up stack configuration for: ${stackName}`); + + // Check if we have the compose file for this stack + const composeResult = await getStackComposeFile(stackName, envId); + + if (!composeResult.success || !composeResult.content) { + // Stack is "external" - we don't have the compose file + log?.(`WARNING: No compose file found for stack "${stackName}"`); + log?.(`This stack may have been created outside Dockhand`); + log?.(`Falling back to container recreation (some settings may be lost)`); + log?.(`TIP: Import the stack in Dockhand to preserve all settings on future updates`); + return false; // Signal to use fallback + } + + log?.(`Found compose file for stack: ${stackName}`); + log?.(`Running: docker compose up -d (service: ${serviceName})`); + + // Use startStack which runs `docker compose up -d` + // This will recreate only containers with changed images + const result = await startStack(stackName, envId); + + if (result.success) { + log?.(`Stack updated successfully via docker compose`); + if (result.output) { + // Log compose output (shows which containers were recreated) + const lines = result.output.split('\n').filter((l: string) => l.trim()); + for (const line of lines) { + log?.(`[compose] ${line}`); + } + } + return true; + } else { + log?.(`docker compose up failed: ${result.error || 'Unknown error'}`); + if (result.output) { + log?.(`Output: ${result.output}`); + } + return false; + } + } catch (error: any) { + log?.(`Stack update error: ${error.message}`); + return false; + } +} diff --git a/src/lib/server/stack-scanner.ts b/src/lib/server/stack-scanner.ts index 138f58c..818d809 100644 --- a/src/lib/server/stack-scanner.ts +++ b/src/lib/server/stack-scanner.ts @@ -256,8 +256,9 @@ export async function adoptStack( return { success: true, adoptedName: finalName }; } catch (err) { - console.error(`[Stack Scanner] Failed to adopt ${stack.name}:`, err); - return { success: false, error: err instanceof Error ? err.message : 'Unknown error' }; + const errorMsg = err instanceof Error ? err.message : String(err); + console.error(`[Stack Scanner] Failed to adopt ${stack.name}:`, errorMsg); + return { success: false, error: errorMsg }; } } diff --git a/src/lib/server/stacks.ts b/src/lib/server/stacks.ts index 248d966..c87ec46 100644 --- a/src/lib/server/stacks.ts +++ b/src/lib/server/stacks.ts @@ -735,7 +735,8 @@ async function loginToRegistries(dockerHost?: string, logPrefix = '[Stack]'): Pr console.error(`${logPrefix} Failed to login to ${registryHost}: ${stderr}`); } } catch (e) { - console.error(`${logPrefix} Error logging into registry ${reg.name}:`, e); + const errorMsg = e instanceof Error ? e.message : String(e); + console.error(`${logPrefix} Error logging into registry ${reg.name}:`, errorMsg); } } } diff --git a/src/lib/server/subprocess-manager.ts b/src/lib/server/subprocess-manager.ts index f29c770..fc1ac59 100644 --- a/src/lib/server/subprocess-manager.ts +++ b/src/lib/server/subprocess-manager.ts @@ -344,7 +344,8 @@ class SubprocessManager { message: notifMessage, type: notificationType }, image).catch((err) => { - console.error('[SubprocessManager] Failed to send notification:', err); + const errorMsg = err instanceof Error ? err.message : String(err); + console.error('[SubprocessManager] Failed to send notification:', errorMsg); }); } break; @@ -369,7 +370,8 @@ class SubprocessManager { }, message.envId ).catch((err) => { - console.error('[SubprocessManager] Failed to send online notification:', err); + const errorMsg = err instanceof Error ? err.message : String(err); + console.error('[SubprocessManager] Failed to send online notification:', errorMsg); }); } else { await sendEventNotification( @@ -381,7 +383,8 @@ class SubprocessManager { }, message.envId ).catch((err) => { - console.error('[SubprocessManager] Failed to send offline notification:', err); + const errorMsg = err instanceof Error ? err.message : String(err); + console.error('[SubprocessManager] Failed to send offline notification:', errorMsg); }); } break; diff --git a/src/routes/api/containers/batch-update-stream/+server.ts b/src/routes/api/containers/batch-update-stream/+server.ts index acf9429..bcd12b9 100644 --- a/src/routes/api/containers/batch-update-stream/+server.ts +++ b/src/routes/api/containers/batch-update-stream/+server.ts @@ -4,9 +4,6 @@ import { authorize } from '$lib/server/authorize'; import { listContainers, inspectContainer, - stopContainer, - removeContainer, - createContainer, pullImage, getTempImageTag, isDigestBasedImage, @@ -18,6 +15,7 @@ import { auditContainer } from '$lib/server/audit'; import { getScannerSettings, scanImage } from '$lib/server/scanner'; import { saveVulnerabilityScan, removePendingContainerUpdate, type VulnerabilityCriteria } from '$lib/server/db'; import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isDockhandContainer } from '$lib/server/scheduler/tasks/update-utils'; +import { recreateContainer, updateStackContainer } from '$lib/server/scheduler/tasks/container-update'; export interface ScanResult { critical: number; @@ -401,81 +399,115 @@ export const POST: RequestHandler = async (event) => { } catch { /* ignore cleanup errors */ } } - // Step 3: Stop container if running - if (wasRunning) { + // Detect if container is part of a Docker Compose stack + const containerLabels = config.Labels || {}; + const composeProject = containerLabels['com.docker.compose.project']; + const composeService = containerLabels['com.docker.compose.service']; + const isStackContainer = !!composeProject; + + // Progress logging function for shared functions + const logProgress = (message: string) => { safeEnqueue({ type: 'progress', containerId, containerName, - step: 'stopping', + step: 'creating', current: i + 1, total: containerIds.length, - message: `Stopping ${containerName}...` + message }); - await stopContainer(containerId, envIdNum); - } + }; - // Step 4: Remove old container - safeEnqueue({ - type: 'progress', - containerId, - containerName, - step: 'removing', - current: i + 1, - total: containerIds.length, - message: `Removing old container ${containerName}...` - }); - await removeContainer(containerId, true, envIdNum); + let updateSuccess = false; + let newContainerId = containerId; - // Prepare port bindings - const ports: { [key: string]: { HostPort: string } } = {}; - if (hostConfig.PortBindings) { - for (const [containerPort, bindings] of Object.entries(hostConfig.PortBindings)) { - if (bindings && (bindings as any[]).length > 0) { - ports[containerPort] = { HostPort: (bindings as any[])[0].HostPort || '' }; + if (isStackContainer) { + // =================================================================== + // STACK CONTAINER: Use docker compose up -d to preserve ALL settings + // =================================================================== + safeEnqueue({ + type: 'progress', + containerId, + containerName, + step: 'creating', + current: i + 1, + total: containerIds.length, + message: `Updating stack ${composeProject} (service: ${composeService})...` + }); + + // Try stack-based update first + const stackSuccess = await updateStackContainer(composeProject, composeService!, envIdNum, logProgress); + + if (stackSuccess) { + updateSuccess = true; + // Find the new container ID + const updatedContainers = await listContainers(true, envIdNum); + const updatedContainer = updatedContainers.find(c => c.name === containerName); + if (updatedContainer) { + newContainerId = updatedContainer.id; + } + } else { + // Fallback: Stack is external, use container recreation with full settings + safeEnqueue({ + type: 'progress', + containerId, + containerName, + step: 'creating', + current: i + 1, + total: containerIds.length, + message: `Recreating ${containerName} (external stack, preserving all settings)...` + }); + + updateSuccess = await recreateContainer(containerName, envIdNum, logProgress); + if (updateSuccess) { + const updatedContainers = await listContainers(true, envIdNum); + const updatedContainer = updatedContainers.find(c => c.name === containerName); + if (updatedContainer) { + newContainerId = updatedContainer.id; + } + } + } + } else { + // =================================================================== + // STANDALONE CONTAINER: Use shared recreation with ALL settings + // =================================================================== + safeEnqueue({ + type: 'progress', + containerId, + containerName, + step: 'creating', + current: i + 1, + total: containerIds.length, + message: `Recreating ${containerName} (preserving all settings)...` + }); + + updateSuccess = await recreateContainer(containerName, envIdNum, logProgress); + if (updateSuccess) { + const updatedContainers = await listContainers(true, envIdNum); + const updatedContainer = updatedContainers.find(c => c.name === containerName); + if (updatedContainer) { + newContainerId = updatedContainer.id; } } } - // Step 5: Create new container - safeEnqueue({ - type: 'progress', - containerId, - containerName, - step: 'creating', - current: i + 1, - total: containerIds.length, - message: `Creating new container ${containerName}...` - }); - - const newContainer = await createContainer({ - name: containerName, - image: imageName, - ports, - volumeBinds: hostConfig.Binds || [], - env: config.Env || [], - labels: config.Labels || {}, - cmd: config.Cmd || undefined, - restartPolicy: hostConfig.RestartPolicy?.Name || 'no', - networkMode: hostConfig.NetworkMode || undefined - }, envIdNum); - - // Step 6: Start if was running - if (wasRunning) { + if (!updateSuccess) { safeEnqueue({ type: 'progress', containerId, containerName, - step: 'starting', + step: 'failed', current: i + 1, total: containerIds.length, - message: `Starting ${containerName}...` + success: false, + error: 'Container recreation failed' }); - await newContainer.start(); + failCount++; + continue; } // Audit log - await auditContainer(event, 'update', newContainer.id, containerName, envIdNum, { batchUpdate: true }); + await auditContainer(event, 'update', newContainerId, containerName, envIdNum, { batchUpdate: true }); // Done with this container - use original containerId for UI consistency safeEnqueue({ diff --git a/src/routes/api/containers/batch-update/+server.ts b/src/routes/api/containers/batch-update/+server.ts index 90a8df8..5f1572d 100644 --- a/src/routes/api/containers/batch-update/+server.ts +++ b/src/routes/api/containers/batch-update/+server.ts @@ -1,15 +1,9 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { authorize } from '$lib/server/authorize'; -import { - listContainers, - inspectContainer, - stopContainer, - removeContainer, - createContainer, - pullImage -} from '$lib/server/docker'; +import { listContainers, pullImage, inspectContainer } from '$lib/server/docker'; import { auditContainer } from '$lib/server/audit'; +import { recreateContainer, updateStackContainer } from '$lib/server/scheduler/tasks/container-update'; export interface BatchUpdateResult { containerId: string; @@ -20,6 +14,8 @@ export interface BatchUpdateResult { /** * Batch update containers by recreating them with latest images. + * Preserves ALL container settings including health checks, resource limits, + * capabilities, DNS, security options, ulimits, and network connections. * Expects JSON body: { containerIds: string[] } */ export const POST: RequestHandler = async (event) => { @@ -62,9 +58,7 @@ export const POST: RequestHandler = async (event) => { // Get full container config const inspectData = await inspectContainer(containerId, envIdNum) as any; - const wasRunning = inspectData.State.Running; const config = inspectData.Config; - const hostConfig = inspectData.HostConfig; const imageName = config.Image; const containerName = container.name; @@ -81,47 +75,65 @@ export const POST: RequestHandler = async (event) => { continue; } - // Stop container if running - if (wasRunning) { - await stopContainer(containerId, envIdNum); - } + // Detect if container is part of a Docker Compose stack + const containerLabels = config.Labels || {}; + const composeProject = containerLabels['com.docker.compose.project']; + const composeService = containerLabels['com.docker.compose.service']; + const isStackContainer = !!composeProject; - // Remove old container - await removeContainer(containerId, true, envIdNum); + let updateSuccess = false; + let newContainerId = containerId; - // Prepare port bindings - const ports: { [key: string]: { HostPort: string } } = {}; - if (hostConfig.PortBindings) { - for (const [containerPort, bindings] of Object.entries(hostConfig.PortBindings)) { - if (bindings && (bindings as any[]).length > 0) { - ports[containerPort] = { HostPort: (bindings as any[])[0].HostPort || '' }; + if (isStackContainer) { + // Stack container: Try docker compose up -d first + const stackSuccess = await updateStackContainer(composeProject, composeService!, envIdNum); + + if (stackSuccess) { + updateSuccess = true; + // Find the new container ID + const updatedContainers = await listContainers(true, envIdNum); + const updatedContainer = updatedContainers.find(c => c.name === containerName); + if (updatedContainer) { + newContainerId = updatedContainer.id; + } + } else { + // Fallback: Stack is external, use container recreation + updateSuccess = await recreateContainer(containerName, envIdNum); + if (updateSuccess) { + const updatedContainers = await listContainers(true, envIdNum); + const updatedContainer = updatedContainers.find(c => c.name === containerName); + if (updatedContainer) { + newContainerId = updatedContainer.id; + } + } + } + } else { + // Standalone container: Use shared recreation with ALL settings + updateSuccess = await recreateContainer(containerName, envIdNum); + if (updateSuccess) { + const updatedContainers = await listContainers(true, envIdNum); + const updatedContainer = updatedContainers.find(c => c.name === containerName); + if (updatedContainer) { + newContainerId = updatedContainer.id; } } } - // Create new container - const newContainer = await createContainer({ - name: containerName, - image: imageName, - ports, - volumeBinds: hostConfig.Binds || [], - env: config.Env || [], - labels: config.Labels || {}, - cmd: config.Cmd || undefined, - restartPolicy: hostConfig.RestartPolicy?.Name || 'no', - networkMode: hostConfig.NetworkMode || undefined - }, envIdNum); - - // Start if was running - if (wasRunning) { - await newContainer.start(); + if (!updateSuccess) { + results.push({ + containerId, + containerName, + success: false, + error: 'Container recreation failed' + }); + continue; } // Audit log - await auditContainer(event, 'update', newContainer.id, containerName, envIdNum, { batchUpdate: true }); + await auditContainer(event, 'update', newContainerId, containerName, envIdNum, { batchUpdate: true }); results.push({ - containerId: newContainer.id, + containerId: newContainerId, containerName, success: true }); diff --git a/src/routes/api/images/push/+server.ts b/src/routes/api/images/push/+server.ts index 0a4b32b..81bdccd 100644 --- a/src/routes/api/images/push/+server.ts +++ b/src/routes/api/images/push/+server.ts @@ -1,6 +1,6 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { inspectImage, tagImage, pushImage } from '$lib/server/docker'; +import { inspectImage, tagImage, pushImage, parseRegistryUrl } from '$lib/server/docker'; import { getRegistry, getEnvironment } from '$lib/server/db'; import { authorize } from '$lib/server/authorize'; import { auditImage } from '$lib/server/audit'; @@ -69,8 +69,8 @@ export const POST: RequestHandler = async (event) => { } // Build the target tag - const registryUrl = new URL(registry.url); - const registryHost = registryUrl.host; + // Parse registry URL to get host and org path separately + const { host: registryHost, fullRegistry } = parseRegistryUrl(registry.url); // Check if this is Docker Hub const isDockerHub = registryHost.includes('docker.io') || @@ -81,7 +81,8 @@ export const POST: RequestHandler = async (event) => { // Use custom tag if provided, otherwise use the base image name const targetImageName = newTag || baseImageName; // Docker Hub doesn't need host prefix - just username/image:tag - const targetTag = isDockerHub ? targetImageName : `${registryHost}/${targetImageName}`; + // For other registries, use full registry path including org (e.g., registry.example.com/org/image:tag) + const targetTag = isDockerHub ? targetImageName : `${fullRegistry}/${targetImageName}`; // Parse repo and tag properly (handle registry:port/image:tag format) // Find the last colon that's after the last slash (that's the tag separator) diff --git a/src/routes/api/registry/catalog/+server.ts b/src/routes/api/registry/catalog/+server.ts index 984a607..e754f45 100644 --- a/src/routes/api/registry/catalog/+server.ts +++ b/src/routes/api/registry/catalog/+server.ts @@ -24,7 +24,7 @@ export const GET: RequestHandler = async ({ url }) => { return json({ error: 'Docker Hub does not support catalog listing. Please use search instead.' }, { status: 400 }); } - const { baseUrl, authHeader } = await getRegistryAuth(registry, 'registry:catalog:*'); + const { baseUrl, orgPath, authHeader } = await getRegistryAuth(registry, 'registry:catalog:*'); // Build catalog URL with pagination let catalogUrl = `${baseUrl}/v2/_catalog?n=${PAGE_SIZE}`; @@ -58,7 +58,13 @@ export const GET: RequestHandler = async ({ url }) => { const data = await response.json(); // The V2 API returns { repositories: [...] } - const repositories: string[] = data.repositories || []; + let repositories: string[] = data.repositories || []; + + // If the registry URL has an organization path, filter to only show repos under that path + if (orgPath) { + const orgPrefix = orgPath.replace(/^\//, ''); // Remove leading slash + repositories = repositories.filter(repo => repo.startsWith(orgPrefix + '/') || repo === orgPrefix); + } // Parse Link header for pagination // Format: ; rel="next" diff --git a/src/routes/api/registry/image/+server.ts b/src/routes/api/registry/image/+server.ts index dd05a08..304f555 100644 --- a/src/routes/api/registry/image/+server.ts +++ b/src/routes/api/registry/image/+server.ts @@ -39,6 +39,7 @@ export const DELETE: RequestHandler = async ({ url }) => { } const { baseUrl, authHeader } = await getRegistryAuth(registry, `repository:${imageName}:pull,push,delete`); + // Note: orgPath is not used here because imageName already contains the full repo path const headers: HeadersInit = { 'Accept': 'application/vnd.docker.distribution.manifest.v2+json' diff --git a/src/routes/api/registry/search/+server.ts b/src/routes/api/registry/search/+server.ts index 6ceeaad..3f1a2f3 100644 --- a/src/routes/api/registry/search/+server.ts +++ b/src/routes/api/registry/search/+server.ts @@ -80,6 +80,7 @@ async function searchPrivateRegistry(registry: any, term: string, limit: number) // Try to directly check if an image exists by querying its tags endpoint async function tryDirectImageLookup(registry: any, imageName: string): Promise { try { + // Note: orgPath is not used here because imageName already contains the full repo path const { baseUrl, authHeader } = await getRegistryAuth(registry, `repository:${imageName}:pull`); const headers: HeadersInit = { @@ -104,6 +105,7 @@ async function tryDirectImageLookup(registry: any, imageName: string): Promise { + // Note: orgPath could be used here to filter results, but search is already term-based const { baseUrl, authHeader } = await getRegistryAuth(registry, 'registry:catalog:*'); const headers: HeadersInit = { diff --git a/src/routes/api/registry/tags/+server.ts b/src/routes/api/registry/tags/+server.ts index 9dab290..7b4f440 100644 --- a/src/routes/api/registry/tags/+server.ts +++ b/src/routes/api/registry/tags/+server.ts @@ -73,6 +73,7 @@ async function fetchDockerHubTags(imageName: string, page: number = 1, pageSize: } async function fetchRegistryTags(registry: any, imageName: string): Promise { + // Note: orgPath is not used here because imageName already contains the full repo path const { baseUrl, authHeader } = await getRegistryAuth(registry, `repository:${imageName}:pull`); const tagsUrl = `${baseUrl}/v2/${imageName}/tags/list`; diff --git a/src/routes/containers/AutoUpdateSettings.svelte b/src/routes/containers/AutoUpdateSettings.svelte index 53c441e..ea52eb1 100644 --- a/src/routes/containers/AutoUpdateSettings.svelte +++ b/src/routes/containers/AutoUpdateSettings.svelte @@ -4,7 +4,7 @@ import CronEditor from '$lib/components/cron-editor.svelte'; import VulnerabilityCriteriaSelector, { type VulnerabilityCriteria } from '$lib/components/VulnerabilityCriteriaSelector.svelte'; import { currentEnvironment } from '$lib/stores/environment'; - import { Ship, Cable, ExternalLink, AlertTriangle } from 'lucide-svelte'; + import { Ship, Cable, ExternalLink, AlertTriangle, Info, Layers } from 'lucide-svelte'; import type { SystemContainerType } from '$lib/types'; interface Props { @@ -12,6 +12,8 @@ cronExpression: string; vulnerabilityCriteria: VulnerabilityCriteria; systemContainer?: SystemContainerType | null; + isComposeContainer?: boolean; + composeStackName?: string; onenablechange?: (enabled: boolean) => void; oncronchange?: (cron: string) => void; oncriteriachange?: (criteria: VulnerabilityCriteria) => void; @@ -22,6 +24,8 @@ cronExpression = $bindable(), vulnerabilityCriteria = $bindable(), systemContainer = null, + isComposeContainer = false, + composeStackName = '', onenablechange, oncronchange, oncriteriachange @@ -93,6 +97,20 @@ /> + {#if isComposeContainer && enabled} +
+ +
+

Stack container update behavior

+

+ This container is part of the {composeStackName} stack. + Updates will use docker compose up -d + to preserve all configuration from the compose file. +

+
+
+ {/if} + {#if enabled} diff --git a/src/routes/containers/EditContainerModal.svelte b/src/routes/containers/EditContainerModal.svelte index d5a28fa..28f09d5 100644 --- a/src/routes/containers/EditContainerModal.svelte +++ b/src/routes/containers/EditContainerModal.svelte @@ -1004,6 +1004,8 @@ bind:autoUpdateEnabled bind:autoUpdateCronExpression bind:vulnerabilityCriteria + {isComposeContainer} + {composeStackName} {configSets} bind:selectedConfigSetId bind:errors diff --git a/src/routes/images/PushToRegistryModal.svelte b/src/routes/images/PushToRegistryModal.svelte index 9d4b287..7b367d6 100644 --- a/src/routes/images/PushToRegistryModal.svelte +++ b/src/routes/images/PushToRegistryModal.svelte @@ -64,8 +64,10 @@ if (isDockerHub(targetRegistry)) { return tag; } - const host = new URL(targetRegistry.url).host; - return `${host}/${tag}`; + // Include both host and path (e.g., registry.example.com/organization) + const url = new URL(targetRegistry.url); + const hostWithPath = url.host + (url.pathname !== '/' ? url.pathname.replace(/\/$/, '') : ''); + return `${hostWithPath}/${tag}`; }); const isProcessing = $derived(pushStatus === 'pushing'); diff --git a/src/routes/registry/CopyToRegistryModal.svelte b/src/routes/registry/CopyToRegistryModal.svelte index 131e59f..b3b9b15 100644 --- a/src/routes/registry/CopyToRegistryModal.svelte +++ b/src/routes/registry/CopyToRegistryModal.svelte @@ -80,16 +80,20 @@ const imageWithTag = imageName.includes(':') ? imageName : `${imageName}:${tagToUse}`; if (sourceRegistry && !isDockerHub(sourceRegistry)) { const urlObj = new URL(sourceRegistry.url); - return `${urlObj.host}/${imageWithTag}`; + // Include both host and path (e.g., registry.example.com/organization) + const hostWithPath = urlObj.host + (urlObj.pathname !== '/' ? urlObj.pathname.replace(/\/$/, '') : ''); + return `${hostWithPath}/${imageWithTag}`; } return imageWithTag; }); const targetImageName = $derived(() => { if (!targetRegistryId || !targetRegistry) return customTag || 'image:latest'; - const host = new URL(targetRegistry.url).host; + // Include both host and path (e.g., registry.example.com/organization) + const url = new URL(targetRegistry.url); + const hostWithPath = url.host + (url.pathname !== '/' ? url.pathname.replace(/\/$/, '') : ''); const tag = customTag ? (customTag.includes(':') ? customTag : customTag + ':latest') : 'image:latest'; - return `${host}/${tag}`; + return `${hostWithPath}/${tag}`; }); const isProcessing = $derived(pullStatus === 'pulling' || scanStatus === 'scanning' || pushStatus === 'pushing'); diff --git a/vite.config.ts b/vite.config.ts index 0e7c2ba..ef1d332 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -791,21 +791,31 @@ async function handleHawserMessage(ws: any, msg: any) { return; } - // Simple token validation (in production this would use argon2 verification) - // For dev mode, just check if a token exists for any environment + // Token validation using proper Argon2id verification (same as production) const tokens = db.prepare('SELECT * FROM hawser_tokens WHERE is_active = 1').all() as any[]; - // For dev mode, accept any valid token format and use the first environment with a token - const token = tokens.find((t: any) => msg.token && msg.token.startsWith(t.token_prefix.slice(0, 4))); + // Validate token using Argon2id hash verification + let matchedToken: any = null; + for (const t of tokens) { + try { + const isValid = await Bun.password.verify(msg.token, t.token); + if (isValid) { + matchedToken = t; + break; + } + } catch { + // If verification fails, continue to next token + } + } - if (!token) { + if (!matchedToken) { console.log('[Hawser WS] Invalid token'); ws.send(JSON.stringify({ type: 'error', error: 'Invalid token' })); ws.close(); return; } - const environmentId = token.environment_id; + const environmentId = matchedToken.environment_id; // Update environment with agent info try {