mirror of
https://github.com/Finsys/dockhand.git
synced 2026-06-17 19:09:33 +03:00
1.0.11
This commit is contained in:
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "dockhand",
|
||||
"private": true,
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bunx --bun vite dev",
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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: () => {} };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user