This commit is contained in:
jarek
2026-01-20 15:39:08 +01:00
parent fe48d63164
commit a3cc26d958
35 changed files with 2045 additions and 273 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "dockhand",
"private": true,
"version": "1.0.10",
"version": "1.0.11",
"type": "module",
"scripts": {
"dev": "bunx --bun vite dev",
+8
View File
@@ -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());
+7
View File
@@ -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
+12
View File
@@ -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",
+4 -2
View File
@@ -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);
}
}
+8 -4
View File
@@ -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' };
}
}
+2 -1
View File
@@ -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 {
+124 -50
View File
@@ -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<Environment[]> {
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<boolean> {
@@ -122,12 +128,22 @@ export async function hasEnvironments(): Promise<boolean> {
export async function getEnvironment(id: number): Promise<Environment | undefined> {
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<Environment | undefined> {
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<Environment, 'id' | 'createdAt' | 'updatedAt'>): Promise<Environment> {
@@ -138,7 +154,7 @@ export async function createEnvironment(env: Omit<Environment, 'id' | 'createdAt
protocol: env.protocol || 'http',
tlsCa: env.tlsCa || null,
tlsCert: env.tlsCert || null,
tlsKey: env.tlsKey || null,
tlsKey: encrypt(env.tlsKey) || null,
icon: env.icon || 'globe',
socketPath: env.socketPath || '/var/run/docker.sock',
collectActivity: env.collectActivity !== false,
@@ -146,9 +162,13 @@ export async function createEnvironment(env: Omit<Environment, 'id' | 'createdAt
highlightChanges: env.highlightChanges !== false,
labels: env.labels || null,
connectionType: env.connectionType || 'socket',
hawserToken: env.hawserToken || null
hawserToken: encrypt(env.hawserToken) || null
}).returning();
return result[0];
return {
...result[0],
tlsKey: decrypt(result[0].tlsKey),
hawserToken: decrypt(result[0].hawserToken)
};
}
export async function updateEnvironment(id: number, env: Partial<Environment>): Promise<Environment | undefined> {
@@ -160,7 +180,7 @@ export async function updateEnvironment(id: number, env: Partial<Environment>):
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<Environment>):
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<boolean> {
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<boolean> {
// =============================================================================
export async function getRegistries(): Promise<Registry[]> {
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<Registry | undefined> {
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<Registry | undefined> {
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<Registry, 'id' | 'createdAt' | 'updatedAt'>): Promise<Registry> {
@@ -225,10 +251,13 @@ export async function createRegistry(registry: Omit<Registry, 'id' | 'createdAt'
name: registry.name,
url: registry.url,
username: registry.username || null,
password: registry.password || null,
password: encrypt(registry.password) || null,
isDefault: registry.isDefault || false
}).returning();
return result[0];
return {
...result[0],
password: decrypt(result[0].password)
};
}
export async function updateRegistry(id: number, registry: Partial<Registry>): Promise<Registry | undefined> {
@@ -237,7 +266,7 @@ export async function updateRegistry(id: number, registry: Partial<Registry>): 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<ConfigSetData[]> {
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<NotificationSettingData[]> {
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<NotificationSe
const row = results[0];
return {
...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;
}
export async function getEnabledNotificationSettings(): Promise<NotificationSettingData[]> {
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<NotificationSettingData>;
@@ -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<LdapConfigData[]> {
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<LdapConfigData | null>
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<LdapConfigData, 'id' | 'create
enabled: data.enabled,
serverUrl: data.serverUrl,
bindDn: data.bindDn || null,
bindPassword: data.bindPassword || null,
bindPassword: encrypt(data.bindPassword) || null,
baseDn: data.baseDn,
userFilter: data.userFilter,
usernameAttribute: data.usernameAttribute,
@@ -1634,7 +1689,7 @@ export async function updateLdapConfig(id: number, data: Partial<LdapConfigData>
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<OidcConfigData[]> {
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<OidcConfigData | null>
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<OidcConfigData, 'id' | 'create
enabled: data.enabled,
issuerUrl: data.issuerUrl,
clientId: data.clientId,
clientSecret: data.clientSecret,
clientSecret: encrypt(data.clientSecret) ?? '',
redirectUri: data.redirectUri,
scopes: data.scopes,
usernameClaim: data.usernameClaim,
@@ -1729,7 +1786,7 @@ export async function updateOidcConfig(id: number, data: Partial<OidcConfigData>
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<GitCredentialData[]> {
return db.select().from(gitCredentials).orderBy(asc(gitCredentials.name)) as Promise<GitCredentialData[]>;
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<GitCredentialData | null> {
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<GitCredentialData>;
}
@@ -1803,9 +1872,9 @@ export async function updateGitCredential(id: number, data: Partial<GitCredentia
// Only update username if provided (empty string clears it)
if (data.username !== undefined) updateData.username = data.username || null;
// Only update password/ssh keys if they have actual values (preserve existing if empty)
if (data.password) updateData.password = data.password;
if (data.sshPrivateKey) updateData.sshPrivateKey = data.sshPrivateKey;
if (data.sshPassphrase) updateData.sshPassphrase = data.sshPassphrase;
if (data.password) updateData.password = encrypt(data.password);
if (data.sshPrivateKey) updateData.sshPrivateKey = encrypt(data.sshPrivateKey);
if (data.sshPassphrase) updateData.sshPassphrase = encrypt(data.sshPassphrase);
await db.update(gitCredentials).set(updateData).where(eq(gitCredentials.id, id));
return getGitCredential(id);
@@ -4215,16 +4284,20 @@ export async function getStackEnvVars(
.orderBy(asc(stackEnvironmentVariables.key));
}
return results.map(row => ({
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
+2 -1
View File
@@ -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);
});
+4 -2
View File
@@ -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<SchemaInfo> {
}
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 };
}
}
+406 -38
View File
@@ -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<string, any> = {};
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<s
return token ? `Bearer ${token}` : null;
} catch (e) {
console.error('Failed to get registry bearer token:', e);
const errorMsg = e instanceof Error ? e.message : String(e);
console.error('[Registry] Failed to get bearer token:', errorMsg);
return null;
}
}
@@ -1364,7 +1689,7 @@ async function getRegistryBearerToken(registry: string, repo: string): Promise<s
* 2. Parse realm, service from challenge
* 3. Request token from realm with credentials (if available)
*
* @param registryUrl - Full registry URL (e.g., 'https://ghcr.io')
* @param registryUrl - Full registry URL (e.g., 'https://ghcr.io' or 'https://registry.example.com/org')
* @param scope - Token scope (e.g., 'registry:catalog:*' or 'repository:user/repo:pull')
* @param credentials - Optional credentials { username, password }
* @returns Authorization header value (e.g., 'Bearer xxx' or 'Basic xxx') or null
@@ -1375,15 +1700,12 @@ export async function getRegistryAuthHeader(
credentials?: { username: string; password: string } | null
): Promise<string | null> {
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<void> {
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;
+565
View File
@@ -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:<base64(iv + authTag + ciphertext)>
*/
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<void> {
// 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<string, string> = 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<string, string | null> = {};
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<string, string | null> = {};
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`);
}
}
+103 -6
View File
@@ -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<SyncResult> {
// 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<SyncResult> {
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<SyncResult> {
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);
+11 -6
View File
@@ -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: () => {} };
}
}
+2 -1
View File
@@ -248,6 +248,7 @@ export async function checkLicenseExpiry(): Promise<void> {
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);
}
}
+30 -8
View File
@@ -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;
}
}
+26 -12
View File
@@ -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<void> {
}
}
} 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);
}
}
+42 -7
View File
@@ -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<void> {
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<void> {
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<void> {
// 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<void> {
}
}
} 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<void> {
}
}
} 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<void> {
}
}
} 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}`);
@@ -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<string>();
// 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<boolean> {
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;
}
}
+3 -2
View File
@@ -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 };
}
}
+2 -1
View File
@@ -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);
}
}
}
+6 -3
View File
@@ -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;
@@ -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({
@@ -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
});
+5 -4
View File
@@ -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)
+8 -2
View File
@@ -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: </v2/_catalog?last=xxx&n=100>; rel="next"
+1
View File
@@ -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'
@@ -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<boolean> {
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<b
// Search through catalog (slow for large registries, limited to first few pages)
async function searchCatalog(registry: any, term: string, limit: number): Promise<string[]> {
// 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 = {
+1
View File
@@ -73,6 +73,7 @@ async function fetchDockerHubTags(imageName: string, page: number = 1, pageSize:
}
async function fetchRegistryTags(registry: any, imageName: string): Promise<TagInfo[]> {
// 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`;
@@ -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 @@
/>
</div>
{#if isComposeContainer && enabled}
<div class="flex items-start gap-2 rounded-md border border-blue-200 bg-blue-50 p-3 dark:border-blue-800 dark:bg-blue-950">
<Layers class="mt-0.5 h-4 w-4 text-blue-600 dark:text-blue-400 shrink-0" />
<div class="text-xs text-blue-800 dark:text-blue-200">
<p class="font-medium">Stack container update behavior</p>
<p class="mt-1 text-blue-700 dark:text-blue-300">
This container is part of the <strong>{composeStackName}</strong> stack.
Updates will use <code class="rounded bg-blue-100 px-1 dark:bg-blue-900">docker compose up -d</code>
to preserve all configuration from the compose file.
</p>
</div>
</div>
{/if}
{#if enabled}
<CronEditor
value={cronExpression}
@@ -114,6 +114,9 @@
autoUpdateEnabled: boolean;
autoUpdateCronExpression: string;
vulnerabilityCriteria: VulnerabilityCriteria;
// Compose stack info
isComposeContainer?: boolean;
composeStackName?: string;
// Config sets
configSets: ConfigSet[];
selectedConfigSetId: string;
@@ -170,6 +173,8 @@
autoUpdateEnabled = $bindable(),
autoUpdateCronExpression = $bindable(),
vulnerabilityCriteria = $bindable(),
isComposeContainer = false,
composeStackName = '',
configSets,
selectedConfigSetId = $bindable(),
errors = $bindable(),
@@ -1273,6 +1278,8 @@
bind:cronExpression={autoUpdateCronExpression}
bind:vulnerabilityCriteria={vulnerabilityCriteria}
systemContainer={detectSystemContainer(image)}
{isComposeContainer}
{composeStackName}
/>
</div>
</div>
@@ -1004,6 +1004,8 @@
bind:autoUpdateEnabled
bind:autoUpdateCronExpression
bind:vulnerabilityCriteria
{isComposeContainer}
{composeStackName}
{configSets}
bind:selectedConfigSetId
bind:errors
+4 -2
View File
@@ -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');
@@ -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');
+16 -6
View File
@@ -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 {