proper src structure, dockerfile, entrypoint

This commit is contained in:
jarek committed 2025-12-29 08:40:11 +01:00
1 parent e5828c7d31
commit 73c9f580a1
556 files changed
+1390

No files matched your search

+88
View File
@@ -0,0 +1,88 @@
import { json } from '@sveltejs/kit';
import { getContainerEvents, getContainerEventContainers, getContainerEventActions, getContainerEventStats, clearContainerEvents, type ContainerEventFilters, type ContainerEventAction } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
// Parse query parameters
const filters: ContainerEventFilters = {};
const envId = url.searchParams.get('environmentId');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('activity', 'view', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
if (envIdNum) {
// Specific environment requested - use it
filters.environmentId = envIdNum;
} else if (auth.isEnterprise && auth.authEnabled && !auth.isAdmin) {
// Enterprise with auth enabled and non-admin: filter by accessible environments
const accessibleEnvIds = await auth.getAccessibleEnvironmentIds();
if (accessibleEnvIds !== null) {
// User has limited access - filter by their accessible environments
if (accessibleEnvIds.length === 0) {
// No access to any environment - return empty
return json({ events: [], total: 0, limit: 100, offset: 0 });
}
filters.environmentIds = accessibleEnvIds;
}
// If accessibleEnvIds is null, user has access to all environments
}
const containerId = url.searchParams.get('containerId');
if (containerId) filters.containerId = containerId;
const containerName = url.searchParams.get('containerName');
if (containerName) filters.containerName = containerName;
// Support multi-select actions filter (comma-separated)
const actions = url.searchParams.get('actions');
if (actions) filters.actions = actions.split(',').filter(Boolean) as ContainerEventAction[];
// Labels filter (comma-separated)
const labels = url.searchParams.get('labels');
if (labels) filters.labels = labels.split(',').filter(Boolean);
const fromDate = url.searchParams.get('fromDate');
if (fromDate) filters.fromDate = fromDate;
const toDate = url.searchParams.get('toDate');
if (toDate) filters.toDate = toDate;
const limit = url.searchParams.get('limit');
if (limit) filters.limit = parseInt(limit);
const offset = url.searchParams.get('offset');
if (offset) filters.offset = parseInt(offset);
const result = await getContainerEvents(filters);
return json(result);
} catch (error) {
console.error('Error fetching container events:', error);
return json({ error: 'Failed to fetch container events' }, { status: 500 });
}
};
export const DELETE: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
// Check permission - admins or users with activity delete permission
// In free edition, all authenticated users can delete
if (auth.authEnabled && !await auth.can('activity', 'delete')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
await clearContainerEvents();
return json({ success: true });
} catch (error) {
console.error('Error clearing container events:', error);
return json({ error: 'Failed to clear container events' }, { status: 500 });
}
};
@@ -0,0 +1,42 @@
import { json } from '@sveltejs/kit';
import { getContainerEventContainers } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('environment_id');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check for activity viewing
if (auth.authEnabled && !await auth.can('activity', 'view', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
let environmentIds: number[] | undefined;
if (envIdNum) {
// Specific environment requested
const containers = await getContainerEventContainers(envIdNum);
return json(containers);
} else if (auth.isEnterprise && auth.authEnabled && !auth.isAdmin) {
// Enterprise with auth enabled and non-admin: filter by accessible environments
const accessibleEnvIds = await auth.getAccessibleEnvironmentIds();
if (accessibleEnvIds !== null) {
if (accessibleEnvIds.length === 0) {
// No access to any environment - return empty list
return json([]);
}
environmentIds = accessibleEnvIds;
}
}
const containers = await getContainerEventContainers(undefined, environmentIds);
return json(containers);
} catch (error) {
console.error('Error fetching container names:', error);
return json({ error: 'Failed to fetch container names' }, { status: 500 });
}
};
+107
View File
@@ -0,0 +1,107 @@
import type { RequestHandler } from './$types';
import { containerEventEmitter } from '$lib/server/event-collector';
import { authorize } from '$lib/server/authorize';
import { json } from '@sveltejs/kit';
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
// Permission check for activity viewing
if (auth.authEnabled && !await auth.can('activity', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
// Get accessible environment IDs for filtering (enterprise only)
let accessibleEnvIds: number[] | null = null;
if (auth.isEnterprise && auth.authEnabled && !auth.isAdmin) {
accessibleEnvIds = await auth.getAccessibleEnvironmentIds();
// If user has no access to any environment, return empty stream
if (accessibleEnvIds !== null && accessibleEnvIds.length === 0) {
const stream = new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
controller.enqueue(encoder.encode(`event: connected\ndata: ${JSON.stringify({ timestamp: new Date().toISOString() })}\n\n`));
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no'
}
});
}
}
let heartbeatInterval: ReturnType<typeof setInterval>;
let handleEvent: ((event: any) => void) | null = null;
let handleEnvStatus: ((status: any) => void) | null = null;
const stream = new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
const sendEvent = (type: string, data: any) => {
try {
const event = `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`;
controller.enqueue(encoder.encode(event));
} catch {
// Ignore errors when client disconnects
}
};
// Send initial connection event
sendEvent('connected', { timestamp: new Date().toISOString() });
// Send heartbeat to keep connection alive (every 5s to prevent Traefik 10s idle timeout)
heartbeatInterval = setInterval(() => {
try {
sendEvent('heartbeat', { timestamp: new Date().toISOString() });
} catch {
clearInterval(heartbeatInterval);
}
}, 5000);
// Listen for new container events (filter by accessible environments)
handleEvent = (event: any) => {
// If accessibleEnvIds is null, user has access to all environments
// Otherwise, filter by accessible environment IDs
if (accessibleEnvIds === null || (event.environmentId && accessibleEnvIds.includes(event.environmentId))) {
sendEvent('activity', event);
}
};
// Listen for environment status changes (online/offline)
handleEnvStatus = (status: any) => {
if (accessibleEnvIds === null || (status.envId && accessibleEnvIds.includes(status.envId))) {
sendEvent('env_status', status);
}
};
containerEventEmitter.on('event', handleEvent);
containerEventEmitter.on('env_status', handleEnvStatus);
},
cancel() {
// Cleanup when client disconnects
clearInterval(heartbeatInterval);
if (handleEvent) {
containerEventEmitter.off('event', handleEvent);
handleEvent = null;
}
if (handleEnvStatus) {
containerEventEmitter.off('env_status', handleEnvStatus);
handleEnvStatus = null;
}
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no'
}
});
};
+42
View File
@@ -0,0 +1,42 @@
import { json } from '@sveltejs/kit';
import { getContainerEventStats } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('environment_id');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check for activity viewing
if (auth.authEnabled && !await auth.can('activity', 'view', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
let environmentIds: number[] | undefined;
if (envIdNum) {
// Specific environment requested
const stats = await getContainerEventStats(envIdNum);
return json(stats);
} else if (auth.isEnterprise && auth.authEnabled && !auth.isAdmin) {
// Enterprise with auth enabled and non-admin: filter by accessible environments
const accessibleEnvIds = await auth.getAccessibleEnvironmentIds();
if (accessibleEnvIds !== null) {
if (accessibleEnvIds.length === 0) {
// No access to any environment - return empty stats
return json({ total: 0, today: 0, byAction: {} });
}
environmentIds = accessibleEnvIds;
}
}
const stats = await getContainerEventStats(undefined, environmentIds);
return json(stats);
} catch (error) {
console.error('Error fetching container event stats:', error);
return json({ error: 'Failed to fetch stats' }, { status: 500 });
}
};
+68
View File
@@ -0,0 +1,68 @@
import { json } from '@sveltejs/kit';
import { authorize, enterpriseRequired } from '$lib/server/authorize';
import { getAuditLogs, getAuditLogUsers, type AuditLogFilters, type AuditEntityType, type AuditAction } from '$lib/server/db';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
// Audit log is Enterprise-only
if (!auth.isEnterprise) {
return json(enterpriseRequired(), { status: 403 });
}
// Check permission
if (!await auth.canViewAuditLog()) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
// Parse query parameters
const filters: AuditLogFilters = {};
// Support multi-select filters (comma-separated)
const usernames = url.searchParams.get('usernames');
if (usernames) filters.usernames = usernames.split(',').filter(Boolean);
const entityTypes = url.searchParams.get('entityTypes');
if (entityTypes) filters.entityTypes = entityTypes.split(',').filter(Boolean) as AuditEntityType[];
const actions = url.searchParams.get('actions');
if (actions) filters.actions = actions.split(',').filter(Boolean) as AuditAction[];
// Legacy single-value support
const username = url.searchParams.get('username');
if (username) filters.usernames = [username];
const entityType = url.searchParams.get('entityType');
if (entityType) filters.entityTypes = [entityType as AuditEntityType];
const action = url.searchParams.get('action');
if (action) filters.actions = [action as AuditAction];
const envId = url.searchParams.get('environmentId');
if (envId) filters.environmentId = parseInt(envId);
// Labels filter (comma-separated)
const labels = url.searchParams.get('labels');
if (labels) filters.labels = labels.split(',').filter(Boolean);
const fromDate = url.searchParams.get('fromDate');
if (fromDate) filters.fromDate = fromDate;
const toDate = url.searchParams.get('toDate');
if (toDate) filters.toDate = toDate;
const limit = url.searchParams.get('limit');
if (limit) filters.limit = parseInt(limit);
const offset = url.searchParams.get('offset');
if (offset) filters.offset = parseInt(offset);
const result = await getAuditLogs(filters);
return json(result);
} catch (error) {
console.error('Error fetching audit logs:', error);
return json({ error: 'Failed to fetch audit logs' }, { status: 500 });
}
};
+79
View File
@@ -0,0 +1,79 @@
import type { RequestHandler } from './$types';
import { authorize, enterpriseRequired } from '$lib/server/authorize';
import { auditEvents, type AuditEventData } from '$lib/server/audit-events';
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
// Audit log is Enterprise-only
if (!auth.isEnterprise) {
return new Response(JSON.stringify(enterpriseRequired()), {
status: 403,
headers: { 'Content-Type': 'application/json' }
});
}
// Check permission
if (!await auth.canViewAuditLog()) {
return new Response(JSON.stringify({ error: 'Permission denied' }), {
status: 403,
headers: { 'Content-Type': 'application/json' }
});
}
const stream = new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
// Send SSE event
const sendEvent = (type: string, data: any) => {
const event = `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`;
try {
controller.enqueue(encoder.encode(event));
} catch (e) {
// Client disconnected
}
};
// Send initial connection event
sendEvent('connected', { timestamp: new Date().toISOString() });
// Send heartbeat to keep connection alive (every 5s to prevent Traefik 10s idle timeout)
const heartbeatInterval = setInterval(() => {
try {
sendEvent('heartbeat', { timestamp: new Date().toISOString() });
} catch {
clearInterval(heartbeatInterval);
}
}, 5000);
// Listen for audit events
const onAuditEvent = (data: AuditEventData) => {
sendEvent('audit', data);
};
auditEvents.on('audit', onAuditEvent);
// Cleanup when client disconnects
const cleanup = () => {
clearInterval(heartbeatInterval);
auditEvents.off('audit', onAuditEvent);
};
// Note: SvelteKit doesn't provide a direct way to detect client disconnect
// The cleanup will happen when the stream errors or the server shuts down
// For production, consider using a WebSocket instead for better connection management
return cleanup;
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no' // Disable nginx buffering
}
});
};
+182
View File
@@ -0,0 +1,182 @@
import { authorize, enterpriseRequired } from '$lib/server/authorize';
import { getAuditLogs, type AuditLogFilters, type AuditEntityType, type AuditAction, type AuditLog } from '$lib/server/db';
import type { RequestHandler } from './$types';
function escapeCSV(value: string | null | undefined): string {
if (value === null || value === undefined) return '';
const str = String(value);
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
}
function formatToJSON(logs: AuditLog[]): string {
return JSON.stringify(logs, null, 2);
}
function formatToCSV(logs: AuditLog[]): string {
const headers = [
'ID',
'Timestamp',
'Username',
'Action',
'Entity Type',
'Entity ID',
'Entity Name',
'Environment ID',
'Description',
'IP Address',
'User Agent',
'Details'
];
const rows = logs.map((log) => [
log.id,
log.createdAt,
escapeCSV(log.username),
escapeCSV(log.action),
escapeCSV(log.entityType),
escapeCSV(log.entityId),
escapeCSV(log.entityName),
log.environmentId ?? '',
escapeCSV(log.description),
escapeCSV(log.ipAddress),
escapeCSV(log.userAgent),
escapeCSV(log.details ? JSON.stringify(log.details) : '')
]);
return [headers.join(','), ...rows.map((row) => row.join(','))].join('\n');
}
function formatToMarkdown(logs: AuditLog[]): string {
const lines: string[] = [];
lines.push('# Audit Log Export');
lines.push('');
lines.push(`Generated: ${new Date().toISOString()}`);
lines.push('');
lines.push(`Total entries: ${logs.length}`);
lines.push('');
lines.push('---');
lines.push('');
for (const log of logs) {
lines.push(`## ${log.action.toUpperCase()} - ${log.entityType}`);
lines.push('');
lines.push(`| Field | Value |`);
lines.push(`|-------|-------|`);
lines.push(`| Timestamp | ${log.createdAt} |`);
lines.push(`| User | ${log.username} |`);
lines.push(`| Action | ${log.action} |`);
lines.push(`| Entity Type | ${log.entityType} |`);
if (log.entityName) lines.push(`| Entity Name | ${log.entityName} |`);
if (log.entityId) lines.push(`| Entity ID | \`${log.entityId}\` |`);
if (log.environmentId) lines.push(`| Environment ID | ${log.environmentId} |`);
if (log.description) lines.push(`| Description | ${log.description} |`);
if (log.ipAddress) lines.push(`| IP Address | ${log.ipAddress} |`);
if (log.details) {
lines.push('');
lines.push('**Details:**');
lines.push('```json');
lines.push(JSON.stringify(log.details, null, 2));
lines.push('```');
}
lines.push('');
lines.push('---');
lines.push('');
}
return lines.join('\n');
}
export const GET: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
// Audit log is Enterprise-only
if (!auth.isEnterprise) {
return new Response(JSON.stringify(enterpriseRequired()), {
status: 403,
headers: { 'Content-Type': 'application/json' }
});
}
// Check permission
if (!await auth.canViewAuditLog()) {
return new Response(JSON.stringify({ error: 'Permission denied' }), {
status: 403,
headers: { 'Content-Type': 'application/json' }
});
}
try {
// Parse query parameters
const filters: AuditLogFilters = {};
const username = url.searchParams.get('username');
if (username) filters.username = username;
const entityType = url.searchParams.get('entityType');
if (entityType) filters.entityType = entityType as AuditEntityType;
const action = url.searchParams.get('action');
if (action) filters.action = action as AuditAction;
const envId = url.searchParams.get('environmentId');
if (envId) filters.environmentId = parseInt(envId);
const fromDate = url.searchParams.get('fromDate');
if (fromDate) filters.fromDate = fromDate;
const toDate = url.searchParams.get('toDate');
if (toDate) filters.toDate = toDate;
// For export, get all matching records (no pagination)
filters.limit = 10000; // Reasonable max limit
const result = await getAuditLogs(filters);
const logs = result.logs;
const format = url.searchParams.get('format') || 'json';
const timestamp = new Date().toISOString().split('T')[0];
let content: string;
let contentType: string;
let filename: string;
switch (format) {
case 'csv':
content = formatToCSV(logs);
contentType = 'text/csv';
filename = `audit-log-${timestamp}.csv`;
break;
case 'md':
content = formatToMarkdown(logs);
contentType = 'text/markdown';
filename = `audit-log-${timestamp}.md`;
break;
case 'json':
default:
content = formatToJSON(logs);
contentType = 'application/json';
filename = `audit-log-${timestamp}.json`;
break;
}
return new Response(content, {
status: 200,
headers: {
'Content-Type': contentType,
'Content-Disposition': `attachment; filename="${filename}"`
}
});
} catch (error) {
console.error('Error exporting audit logs:', error);
return new Response(JSON.stringify({ error: 'Failed to export audit logs' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
};
+26
View File
@@ -0,0 +1,26 @@
import { json } from '@sveltejs/kit';
import { authorize, enterpriseRequired } from '$lib/server/authorize';
import { getAuditLogUsers } from '$lib/server/db';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
// Audit log is Enterprise-only
if (!auth.isEnterprise) {
return json(enterpriseRequired(), { status: 403 });
}
// Check permission
if (!await auth.canViewAuditLog()) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const users = await getAuditLogUsers();
return json(users);
} catch (error) {
console.error('Error fetching audit log users:', error);
return json({ error: 'Failed to fetch audit log users' }, { status: 500 });
}
};
+81
View File
@@ -0,0 +1,81 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { authorize } from '$lib/server/authorize';
import { getLdapConfigs, createLdapConfig } from '$lib/server/db';
// GET /api/auth/ldap - List all LDAP configurations
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
// Allow access when auth is disabled (setup mode) or when user is admin
if (auth.authEnabled && (!auth.isAuthenticated || !auth.isAdmin)) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
if (!auth.isEnterprise) {
return json({ error: 'Enterprise license required' }, { status: 403 });
}
try {
const configs = await getLdapConfigs();
// Don't return passwords
const sanitized = configs.map(config => ({
...config,
bindPassword: config.bindPassword ? '********' : undefined
}));
return json(sanitized);
} catch (error) {
console.error('Failed to get LDAP configs:', error);
return json({ error: 'Failed to get LDAP configurations' }, { status: 500 });
}
};
// POST /api/auth/ldap - Create a new LDAP configuration
export const POST: RequestHandler = async ({ request, cookies }) => {
const auth = await authorize(cookies);
// Allow access when auth is disabled (setup mode) or when user is admin
if (auth.authEnabled && (!auth.isAuthenticated || !auth.isAdmin)) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
if (!auth.isEnterprise) {
return json({ error: 'Enterprise license required' }, { status: 403 });
}
try {
const data = await request.json();
// Validate required fields
if (!data.name || !data.serverUrl || !data.baseDn) {
return json({ error: 'Name, server URL, and base DN are required' }, { status: 400 });
}
const config = await createLdapConfig({
name: data.name,
enabled: data.enabled ?? false,
serverUrl: data.serverUrl,
bindDn: data.bindDn || undefined,
bindPassword: data.bindPassword || undefined,
baseDn: data.baseDn,
userFilter: data.userFilter || '(uid={{username}})',
usernameAttribute: data.usernameAttribute || 'uid',
emailAttribute: data.emailAttribute || 'mail',
displayNameAttribute: data.displayNameAttribute || 'cn',
groupBaseDn: data.groupBaseDn || undefined,
groupFilter: data.groupFilter || undefined,
adminGroup: data.adminGroup || undefined,
roleMappings: data.roleMappings || undefined,
tlsEnabled: data.tlsEnabled ?? false,
tlsCa: data.tlsCa || undefined
});
return json({
...config,
bindPassword: config.bindPassword ? '********' : undefined
}, { status: 201 });
} catch (error) {
console.error('Failed to create LDAP config:', error);
return json({ error: 'Failed to create LDAP configuration' }, { status: 500 });
}
};
+131
View File
@@ -0,0 +1,131 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { authorize } from '$lib/server/authorize';
import { getLdapConfig, updateLdapConfig, deleteLdapConfig } from '$lib/server/db';
// GET /api/auth/ldap/[id] - Get a specific LDAP configuration
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
// Allow access when auth is disabled (setup mode) or when user is admin
if (auth.authEnabled && (!auth.isAuthenticated || !auth.isAdmin)) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
if (!auth.isEnterprise) {
return json({ error: 'Enterprise license required' }, { status: 403 });
}
const id = parseInt(params.id!, 10);
if (isNaN(id)) {
return json({ error: 'Invalid ID' }, { status: 400 });
}
try {
const config = await getLdapConfig(id);
if (!config) {
return json({ error: 'LDAP configuration not found' }, { status: 404 });
}
return json({
...config,
bindPassword: config.bindPassword ? '********' : undefined
});
} catch (error) {
console.error('Failed to get LDAP config:', error);
return json({ error: 'Failed to get LDAP configuration' }, { status: 500 });
}
};
// PUT /api/auth/ldap/[id] - Update a LDAP configuration
export const PUT: RequestHandler = async ({ params, request, cookies }) => {
const auth = await authorize(cookies);
// Allow access when auth is disabled (setup mode) or when user is admin
if (auth.authEnabled && (!auth.isAuthenticated || !auth.isAdmin)) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
if (!auth.isEnterprise) {
return json({ error: 'Enterprise license required' }, { status: 403 });
}
const id = parseInt(params.id!, 10);
if (isNaN(id)) {
return json({ error: 'Invalid ID' }, { status: 400 });
}
try {
const existing = await getLdapConfig(id);
if (!existing) {
return json({ error: 'LDAP configuration not found' }, { status: 404 });
}
const data = await request.json();
// Don't update password if it's the masked value
const updateData: any = {};
if (data.name !== undefined) updateData.name = data.name;
if (data.enabled !== undefined) updateData.enabled = data.enabled;
if (data.serverUrl !== undefined) updateData.serverUrl = data.serverUrl;
if (data.bindDn !== undefined) updateData.bindDn = data.bindDn;
if (data.bindPassword !== undefined && data.bindPassword !== '********') {
updateData.bindPassword = data.bindPassword;
}
if (data.baseDn !== undefined) updateData.baseDn = data.baseDn;
if (data.userFilter !== undefined) updateData.userFilter = data.userFilter;
if (data.usernameAttribute !== undefined) updateData.usernameAttribute = data.usernameAttribute;
if (data.emailAttribute !== undefined) updateData.emailAttribute = data.emailAttribute;
if (data.displayNameAttribute !== undefined) updateData.displayNameAttribute = data.displayNameAttribute;
if (data.groupBaseDn !== undefined) updateData.groupBaseDn = data.groupBaseDn;
if (data.groupFilter !== undefined) updateData.groupFilter = data.groupFilter;
if (data.adminGroup !== undefined) updateData.adminGroup = data.adminGroup;
if (data.roleMappings !== undefined) updateData.roleMappings = data.roleMappings;
if (data.tlsEnabled !== undefined) updateData.tlsEnabled = data.tlsEnabled;
if (data.tlsCa !== undefined) updateData.tlsCa = data.tlsCa;
const config = await updateLdapConfig(id, updateData);
if (!config) {
return json({ error: 'Failed to update configuration' }, { status: 500 });
}
return json({
...config,
bindPassword: config.bindPassword ? '********' : undefined
});
} catch (error) {
console.error('Failed to update LDAP config:', error);
return json({ error: 'Failed to update LDAP configuration' }, { status: 500 });
}
};
// DELETE /api/auth/ldap/[id] - Delete a LDAP configuration
export const DELETE: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
// Allow access when auth is disabled (setup mode) or when user is admin
if (auth.authEnabled && (!auth.isAuthenticated || !auth.isAdmin)) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
if (!auth.isEnterprise) {
return json({ error: 'Enterprise license required' }, { status: 403 });
}
const id = parseInt(params.id!, 10);
if (isNaN(id)) {
return json({ error: 'Invalid ID' }, { status: 400 });
}
try {
const deleted = await deleteLdapConfig(id);
if (!deleted) {
return json({ error: 'LDAP configuration not found' }, { status: 404 });
}
return json({ success: true });
} catch (error) {
console.error('Failed to delete LDAP config:', error);
return json({ error: 'Failed to delete LDAP configuration' }, { status: 500 });
}
};
@@ -0,0 +1,37 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { testLdapConnection } from '$lib/server/auth';
import { authorize } from '$lib/server/authorize';
import { getLdapConfig } from '$lib/server/db';
// POST /api/auth/ldap/[id]/test - Test LDAP connection
export const POST: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
// Allow access when auth is disabled (setup mode) or when user is admin
if (auth.authEnabled && (!auth.isAuthenticated || !auth.isAdmin)) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
if (!auth.isEnterprise) {
return json({ error: 'Enterprise license required' }, { status: 403 });
}
const id = parseInt(params.id!, 10);
if (isNaN(id)) {
return json({ error: 'Invalid ID' }, { status: 400 });
}
try {
const config = await getLdapConfig(id);
if (!config) {
return json({ error: 'LDAP configuration not found' }, { status: 404 });
}
const result = await testLdapConnection(id);
return json(result);
} catch (error) {
console.error('Failed to test LDAP connection:', error);
return json({ error: 'Failed to test LDAP connection' }, { status: 500 });
}
};
+117
View File
@@ -0,0 +1,117 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import {
authenticateLocal,
authenticateLdap,
getEnabledLdapConfigs,
createUserSession,
isRateLimited,
recordFailedAttempt,
clearRateLimit,
verifyMfaToken,
isAuthEnabled
} from '$lib/server/auth';
import { getUser, getUserByUsername } from '$lib/server/db';
// POST /api/auth/login - Authenticate user
export const POST: RequestHandler = async ({ request, cookies, getClientAddress }) => {
// Check if auth is enabled
if (!(await isAuthEnabled())) {
return json({ error: 'Authentication is not enabled' }, { status: 400 });
}
try {
const { username, password, mfaToken, provider = 'local' } = await request.json();
if (!username || !password) {
return json({ error: 'Username and password are required' }, { status: 400 });
}
// Rate limiting by IP and username
const clientIp = getClientAddress();
const rateLimitKey = `${clientIp}:${username}`;
const { limited, retryAfter } = isRateLimited(rateLimitKey);
if (limited) {
return json(
{ error: `Too many login attempts. Please try again in ${retryAfter} seconds.` },
{ status: 429 }
);
}
// Attempt authentication based on provider
let result: any;
let authProviderType: 'local' | 'ldap' | 'oidc' = 'local';
if (provider.startsWith('ldap:')) {
// LDAP provider with specific config ID (e.g., "ldap:1")
const configId = parseInt(provider.split(':')[1], 10);
result = await authenticateLdap(username, password, configId);
authProviderType = 'ldap';
} else if (provider === 'ldap') {
// Generic LDAP (will try all enabled configs)
result = await authenticateLdap(username, password);
authProviderType = 'ldap';
} else {
result = await authenticateLocal(username, password);
authProviderType = 'local';
}
if (!result.success) {
recordFailedAttempt(rateLimitKey);
return json({ error: result.error || 'Authentication failed' }, { status: 401 });
}
// Handle MFA if required
if (result.requiresMfa) {
if (!mfaToken) {
// Return that MFA is required
return json({ requiresMfa: true }, { status: 200 });
}
// Verify MFA token
const user = await getUserByUsername(username);
if (!user || !(await verifyMfaToken(user.id, mfaToken))) {
recordFailedAttempt(rateLimitKey);
return json({ error: 'Invalid MFA code' }, { status: 401 });
}
// MFA verified, create session
const session = await createUserSession(user.id, authProviderType, cookies);
clearRateLimit(rateLimitKey);
return json({
success: true,
user: {
id: user.id,
username: user.username,
email: user.email,
displayName: user.displayName,
isAdmin: user.isAdmin
}
});
}
// No MFA, create session directly
if (result.user) {
const session = await createUserSession(result.user.id, authProviderType, cookies);
clearRateLimit(rateLimitKey);
return json({
success: true,
user: {
id: result.user.id,
username: result.user.username,
email: result.user.email,
displayName: result.user.displayName,
isAdmin: result.user.isAdmin
}
});
}
return json({ error: 'Authentication failed' }, { status: 401 });
} catch (error) {
console.error('Login error:', error);
return json({ error: 'Login failed' }, { status: 500 });
}
};
+14
View File
@@ -0,0 +1,14 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { destroySession } from '$lib/server/auth';
// POST /api/auth/logout - End session
export const POST: RequestHandler = async ({ cookies }) => {
try {
await destroySession(cookies);
return json({ success: true });
} catch (error) {
console.error('Logout error:', error);
return json({ error: 'Logout failed' }, { status: 500 });
}
};
+88
View File
@@ -0,0 +1,88 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { authorize } from '$lib/server/authorize';
import {
getOidcConfigs,
createOidcConfig,
type OidcConfig
} from '$lib/server/db';
// GET /api/auth/oidc - List all OIDC configurations
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
// When auth is enabled, require authentication and settings:view permission
if (auth.authEnabled) {
if (!auth.isAuthenticated) {
return json({ error: 'Authentication required' }, { status: 401 });
}
if (!await auth.can('settings', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
}
try {
const configs = await getOidcConfigs();
// Sanitize sensitive data
const sanitized = configs.map(config => ({
...config,
clientSecret: config.clientSecret ? '********' : ''
}));
return json(sanitized);
} catch (error) {
console.error('Failed to get OIDC configs:', error);
return json({ error: 'Failed to get OIDC configurations' }, { status: 500 });
}
};
// POST /api/auth/oidc - Create new OIDC configuration
export const POST: RequestHandler = async ({ request, cookies }) => {
const auth = await authorize(cookies);
// When auth is enabled, require authentication and settings:edit permission
if (auth.authEnabled) {
if (!auth.isAuthenticated) {
return json({ error: 'Authentication required' }, { status: 401 });
}
if (!await auth.can('settings', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
}
try {
const data = await request.json();
// Validate required fields
const required = ['name', 'issuerUrl', 'clientId', 'clientSecret', 'redirectUri'];
for (const field of required) {
if (!data[field]) {
return json({ error: `Missing required field: ${field}` }, { status: 400 });
}
}
const config = await createOidcConfig({
name: data.name,
enabled: data.enabled ?? false,
issuerUrl: data.issuerUrl,
clientId: data.clientId,
clientSecret: data.clientSecret,
redirectUri: data.redirectUri,
scopes: data.scopes || 'openid profile email',
usernameClaim: data.usernameClaim || 'preferred_username',
emailClaim: data.emailClaim || 'email',
displayNameClaim: data.displayNameClaim || 'name',
adminClaim: data.adminClaim || undefined,
adminValue: data.adminValue || undefined,
roleMappingsClaim: data.roleMappingsClaim || 'groups',
roleMappings: data.roleMappings || undefined
});
return json({
...config,
clientSecret: '********'
}, { status: 201 });
} catch (error: any) {
console.error('Failed to create OIDC config:', error);
return json({ error: error.message || 'Failed to create OIDC configuration' }, { status: 500 });
}
};
+136
View File
@@ -0,0 +1,136 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { authorize } from '$lib/server/authorize';
import {
getOidcConfig,
updateOidcConfig,
deleteOidcConfig
} from '$lib/server/db';
// GET /api/auth/oidc/[id] - Get specific OIDC configuration
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
// When auth is enabled, require authentication and settings:view permission
if (auth.authEnabled) {
if (!auth.isAuthenticated) {
return json({ error: 'Authentication required' }, { status: 401 });
}
if (!await auth.can('settings', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
}
const id = parseInt(params.id || '');
if (isNaN(id)) {
return json({ error: 'Invalid configuration ID' }, { status: 400 });
}
try {
const config = await getOidcConfig(id);
if (!config) {
return json({ error: 'OIDC configuration not found' }, { status: 404 });
}
return json({
...config,
clientSecret: config.clientSecret ? '********' : ''
});
} catch (error) {
console.error('Failed to get OIDC config:', error);
return json({ error: 'Failed to get OIDC configuration' }, { status: 500 });
}
};
// PUT /api/auth/oidc/[id] - Update OIDC configuration
export const PUT: RequestHandler = async ({ params, request, cookies }) => {
const auth = await authorize(cookies);
// When auth is enabled, require authentication and settings:edit permission
if (auth.authEnabled) {
if (!auth.isAuthenticated) {
return json({ error: 'Authentication required' }, { status: 401 });
}
if (!await auth.can('settings', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
}
const id = parseInt(params.id || '');
if (isNaN(id)) {
return json({ error: 'Invalid configuration ID' }, { status: 400 });
}
try {
const existing = await getOidcConfig(id);
if (!existing) {
return json({ error: 'OIDC configuration not found' }, { status: 404 });
}
const data = await request.json();
// Don't update clientSecret if it's the masked value
const updateData: any = {};
if (data.name !== undefined) updateData.name = data.name;
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 && data.clientSecret !== '********') {
updateData.clientSecret = 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;
if (data.emailClaim !== undefined) updateData.emailClaim = data.emailClaim;
if (data.displayNameClaim !== undefined) updateData.displayNameClaim = data.displayNameClaim;
if (data.adminClaim !== undefined) updateData.adminClaim = data.adminClaim;
if (data.adminValue !== undefined) updateData.adminValue = data.adminValue;
if (data.roleMappingsClaim !== undefined) updateData.roleMappingsClaim = data.roleMappingsClaim;
if (data.roleMappings !== undefined) updateData.roleMappings = data.roleMappings;
const config = await updateOidcConfig(id, updateData);
if (!config) {
return json({ error: 'Failed to update OIDC configuration' }, { status: 500 });
}
return json({
...config,
clientSecret: config.clientSecret ? '********' : ''
});
} catch (error: any) {
console.error('Failed to update OIDC config:', error);
return json({ error: error.message || 'Failed to update OIDC configuration' }, { status: 500 });
}
};
// DELETE /api/auth/oidc/[id] - Delete OIDC configuration
export const DELETE: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
// When auth is enabled, require authentication and settings:edit permission
if (auth.authEnabled) {
if (!auth.isAuthenticated) {
return json({ error: 'Authentication required' }, { status: 401 });
}
if (!await auth.can('settings', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
}
const id = parseInt(params.id || '');
if (isNaN(id)) {
return json({ error: 'Invalid configuration ID' }, { status: 400 });
}
try {
const deleted = await deleteOidcConfig(id);
if (!deleted) {
return json({ error: 'OIDC configuration not found' }, { status: 404 });
}
return json({ success: true });
} catch (error) {
console.error('Failed to delete OIDC config:', error);
return json({ error: 'Failed to delete OIDC configuration' }, { status: 500 });
}
};
@@ -0,0 +1,77 @@
import { json, redirect } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { buildOidcAuthorizationUrl, isAuthEnabled } from '$lib/server/auth';
import { getOidcConfig } from '$lib/server/db';
// GET /api/auth/oidc/[id]/initiate - Start OIDC authentication flow
export const GET: RequestHandler = async ({ params, url }) => {
// Check if auth is enabled
if (!isAuthEnabled()) {
return json({ error: 'Authentication is not enabled' }, { status: 400 });
}
const id = parseInt(params.id || '');
if (isNaN(id)) {
return json({ error: 'Invalid configuration ID' }, { status: 400 });
}
// Get redirect URL from query params
const redirectUrl = url.searchParams.get('redirect') || '/';
try {
const config = await getOidcConfig(id);
if (!config || !config.enabled) {
return json({ error: 'OIDC provider not found or disabled' }, { status: 404 });
}
const result = await buildOidcAuthorizationUrl(id, redirectUrl);
if ('error' in result) {
return json({ error: result.error }, { status: 500 });
}
// Redirect to the IdP
throw redirect(302, result.url);
} catch (error: any) {
// Re-throw redirect
if (error.status === 302) {
throw error;
}
console.error('Failed to initiate OIDC:', error);
return json({ error: error.message || 'Failed to initiate SSO' }, { status: 500 });
}
};
// POST /api/auth/oidc/[id]/initiate - Get authorization URL without redirect
export const POST: RequestHandler = async ({ params, request }) => {
// Check if auth is enabled
if (!isAuthEnabled()) {
return json({ error: 'Authentication is not enabled' }, { status: 400 });
}
const id = parseInt(params.id || '');
if (isNaN(id)) {
return json({ error: 'Invalid configuration ID' }, { status: 400 });
}
try {
const body = await request.json().catch(() => ({}));
const redirectUrl = body.redirect || '/';
const config = await getOidcConfig(id);
if (!config || !config.enabled) {
return json({ error: 'OIDC provider not found or disabled' }, { status: 404 });
}
const result = await buildOidcAuthorizationUrl(id, redirectUrl);
if ('error' in result) {
return json({ error: result.error }, { status: 500 });
}
return json({ url: result.url });
} catch (error: any) {
console.error('Failed to get OIDC authorization URL:', error);
return json({ error: error.message || 'Failed to initiate SSO' }, { status: 500 });
}
};
@@ -0,0 +1,28 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { validateSession, testOidcConnection, isAuthEnabled } from '$lib/server/auth';
// POST /api/auth/oidc/[id]/test - Test OIDC connection
export const POST: RequestHandler = async ({ params, cookies }) => {
// When auth is disabled, allow access (for initial setup)
// When auth is enabled, require admin
if (isAuthEnabled()) {
const user = await validateSession(cookies);
if (!user || !user.isAdmin) {
return json({ error: 'Admin access required' }, { status: 403 });
}
}
const id = parseInt(params.id || '');
if (isNaN(id)) {
return json({ error: 'Invalid configuration ID' }, { status: 400 });
}
try {
const result = await testOidcConnection(id);
return json(result);
} catch (error: any) {
console.error('Failed to test OIDC connection:', error);
return json({ success: false, error: error.message || 'Test failed' }, { status: 500 });
}
};
@@ -0,0 +1,53 @@
import { json, redirect } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { handleOidcCallback, createUserSession, isAuthEnabled } from '$lib/server/auth';
// GET /api/auth/oidc/callback - Handle OIDC callback from IdP
export const GET: RequestHandler = async ({ url, cookies }) => {
// Check if auth is enabled
if (!isAuthEnabled()) {
throw redirect(302, '/login?error=auth_disabled');
}
// Get parameters from URL
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
const error = url.searchParams.get('error');
const errorDescription = url.searchParams.get('error_description');
// Handle error from IdP
if (error) {
console.error('OIDC error from IdP:', error, errorDescription);
const errorMsg = encodeURIComponent(errorDescription || error);
throw redirect(302, `/login?error=${errorMsg}`);
}
// Validate required parameters
if (!code || !state) {
throw redirect(302, '/login?error=invalid_callback');
}
try {
const result = await handleOidcCallback(code, state);
if (!result.success || !result.user) {
const errorMsg = encodeURIComponent(result.error || 'Authentication failed');
throw redirect(302, `/login?error=${errorMsg}`);
}
// Create session
await createUserSession(result.user.id, 'oidc', cookies);
// Redirect to the original destination or home
const redirectUrl = result.redirectUrl || '/';
throw redirect(302, redirectUrl);
} catch (error: any) {
// Re-throw redirect
if (error.status === 302) {
throw error;
}
console.error('OIDC callback error:', error);
const errorMsg = encodeURIComponent(error.message || 'Authentication failed');
throw redirect(302, `/login?error=${errorMsg}`);
}
};
+54
View File
@@ -0,0 +1,54 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { isAuthEnabled, getEnabledLdapConfigs, getEnabledOidcConfigs } from '$lib/server/auth';
import { getAuthSettings } from '$lib/server/db';
import { isEnterprise } from '$lib/server/license';
// GET /api/auth/providers - Get available authentication providers
export const GET: RequestHandler = async () => {
if (!(await isAuthEnabled())) {
return json({ providers: [] });
}
try {
// Fetch all provider configs in parallel
const [settings, enterpriseEnabled, oidcConfigs] = await Promise.all([
getAuthSettings(),
isEnterprise(),
getEnabledOidcConfigs()
]);
const ldapConfigs = enterpriseEnabled ? await getEnabledLdapConfigs() : [];
const providers: { id: string; name: string; type: 'local' | 'ldap' | 'oidc'; initiateUrl?: string }[] = [];
// Local auth is always available when auth is enabled
providers.push({ id: 'local', name: 'Local', type: 'local' });
// Add enabled LDAP providers (enterprise only)
for (const config of ldapConfigs) {
providers.push({
id: `ldap:${config.id}`,
name: config.name,
type: 'ldap'
});
}
// Add enabled OIDC providers (free for all)
for (const config of oidcConfigs) {
providers.push({
id: `oidc:${config.id}`,
name: config.name,
type: 'oidc',
initiateUrl: `/api/auth/oidc/${config.id}/initiate`
});
}
return json({
providers,
defaultProvider: settings.defaultProvider || 'local'
});
} catch (error) {
console.error('Failed to get auth providers:', error);
return json({ providers: [{ id: 'local', name: 'Local', type: 'local' }] });
}
};
+46
View File
@@ -0,0 +1,46 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { validateSession, isAuthEnabled } from '$lib/server/auth';
import { getAuthSettings } from '$lib/server/db';
// GET /api/auth/session - Get current session/user
export const GET: RequestHandler = async ({ cookies }) => {
try {
const authEnabled = await isAuthEnabled();
if (!authEnabled) {
// Auth is disabled, return anonymous session
return json({
authenticated: false,
authEnabled: false
});
}
const user = await validateSession(cookies);
if (!user) {
return json({
authenticated: false,
authEnabled: true
});
}
return json({
authenticated: true,
authEnabled: true,
user: {
id: user.id,
username: user.username,
email: user.email,
displayName: user.displayName,
avatar: user.avatar,
isAdmin: user.isAdmin,
provider: user.provider,
permissions: user.permissions
}
});
} catch (error) {
console.error('Session check error:', error);
return json({ error: 'Failed to check session' }, { status: 500 });
}
};
+71
View File
@@ -0,0 +1,71 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { getAuthSettings, updateAuthSettings, countAdminUsers } from '$lib/server/db';
import { isEnterprise } from '$lib/server/license';
import { authorize } from '$lib/server/authorize';
// GET /api/auth/settings - Get auth settings
// Public when auth is disabled, requires authentication when enabled
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
// When auth is enabled, require authentication first, then settings:view permission
if (auth.authEnabled) {
if (!auth.isAuthenticated) {
return json({ error: 'Authentication required' }, { status: 401 });
}
if (!await auth.can('settings', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
}
try {
const settings = await getAuthSettings();
return json(settings);
} catch (error) {
console.error('Failed to get auth settings:', error);
return json({ error: 'Failed to get auth settings' }, { status: 500 });
}
};
// PUT /api/auth/settings - Update auth settings
// Requires authentication and settings:edit permission
export const PUT: RequestHandler = async ({ request, cookies }) => {
const auth = await authorize(cookies);
// When auth is enabled, require authentication first, then settings:edit permission
if (auth.authEnabled) {
if (!auth.isAuthenticated) {
return json({ error: 'Authentication required' }, { status: 401 });
}
if (!await auth.can('settings', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
}
try {
const data = await request.json();
// Check if trying to enable auth without required users
if (data.authEnabled === true) {
const userCount = await countAdminUsers();
// PostgreSQL returns bigint for count(*), convert to number for comparison
if (Number(userCount) === 0) {
const enterprise = await isEnterprise();
const errorMessage = enterprise
? 'Cannot enable authentication without an admin user. Create a user and assign them the Admin role first.'
: 'Cannot enable authentication without any users. Create a user first.';
return json({
error: errorMessage,
requiresUser: true
}, { status: 400 });
}
}
const settings = await updateAuthSettings(data);
return json(settings);
} catch (error) {
console.error('Failed to update auth settings:', error);
return json({ error: 'Failed to update auth settings' }, { status: 500 });
}
};
+40
View File
@@ -0,0 +1,40 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getAutoUpdateSettings } from '$lib/server/db';
/**
* Batch endpoint to get all auto-update settings for an environment.
* Returns a map of containerName -> settings for efficient lookup.
*/
export const GET: RequestHandler = async ({ url }) => {
try {
const envIdParam = url.searchParams.get('env');
const envId = envIdParam ? parseInt(envIdParam) : undefined;
const settings = await getAutoUpdateSettings(envId);
// Convert to a map keyed by container name for efficient frontend lookup
const settingsMap: Record<string, {
enabled: boolean;
scheduleType: string;
cronExpression: string | null;
vulnerabilityCriteria: string;
}> = {};
for (const setting of settings) {
if (setting.enabled) {
settingsMap[setting.containerName] = {
enabled: setting.enabled,
scheduleType: setting.scheduleType,
cronExpression: setting.cronExpression,
vulnerabilityCriteria: setting.vulnerabilityCriteria || 'never'
};
}
}
return json(settingsMap);
} catch (error) {
console.error('Failed to get auto-update settings:', error);
return json({ error: 'Failed to get auto-update settings' }, { status: 500 });
}
};
@@ -0,0 +1,126 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import {
getAutoUpdateSetting,
upsertAutoUpdateSetting,
deleteAutoUpdateSetting,
deleteAutoUpdateSchedule
} from '$lib/server/db';
import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler';
export const GET: RequestHandler = async ({ params, url }) => {
try {
const containerName = decodeURIComponent(params.containerName);
const envIdParam = url.searchParams.get('env');
const envId = envIdParam ? parseInt(envIdParam) : undefined;
const setting = await getAutoUpdateSetting(containerName, envId);
if (!setting) {
return json({
enabled: false,
scheduleType: 'daily',
cronExpression: '0 3 * * *',
vulnerabilityCriteria: 'never'
});
}
// Return with camelCase keys
return json({
...setting,
scheduleType: setting.scheduleType,
cronExpression: setting.cronExpression,
vulnerabilityCriteria: setting.vulnerabilityCriteria || 'never'
});
} catch (error) {
console.error('Failed to get auto-update setting:', error);
return json({ error: 'Failed to get auto-update setting' }, { status: 500 });
}
};
export const POST: RequestHandler = async ({ params, url, request }) => {
try {
const containerName = decodeURIComponent(params.containerName);
const envIdParam = url.searchParams.get('env');
const envId = envIdParam ? parseInt(envIdParam) : undefined;
const body = await request.json();
// Accept both camelCase and snake_case for backward compatibility
const enabled = body.enabled;
const cronExpression = body.cronExpression ?? body.cron_expression;
const vulnerabilityCriteria = body.vulnerabilityCriteria ?? body.vulnerability_criteria;
// Hard delete when disabled
if (enabled === false) {
await deleteAutoUpdateSchedule(containerName, envId);
return json({ success: true, deleted: true });
}
// Auto-detect schedule type from cron expression for backward compatibility
let scheduleType: 'daily' | 'weekly' | 'custom' = 'custom';
if (cronExpression) {
const parts = cronExpression.split(' ');
if (parts.length >= 5) {
const [, , day, month, dow] = parts;
if (dow !== '*' && day === '*' && month === '*') {
scheduleType = 'weekly';
} else if (day === '*' && month === '*' && dow === '*') {
scheduleType = 'daily';
}
}
}
const setting = await upsertAutoUpdateSetting(
containerName,
{
enabled: Boolean(enabled),
scheduleType: scheduleType,
cronExpression: cronExpression || null,
vulnerabilityCriteria: vulnerabilityCriteria || 'never'
},
envId
);
// Register or unregister schedule with croner
if (setting.enabled && setting.cronExpression) {
await registerSchedule(setting.id, 'container_update', setting.environmentId);
} else {
unregisterSchedule(setting.id, 'container_update');
}
// Return with camelCase keys
return json({
...setting,
scheduleType: setting.scheduleType,
cronExpression: setting.cronExpression,
vulnerabilityCriteria: setting.vulnerabilityCriteria || 'never'
});
} catch (error) {
console.error('Failed to save auto-update setting:', error);
return json({ error: 'Failed to save auto-update setting' }, { status: 500 });
}
};
export const DELETE: RequestHandler = async ({ params, url }) => {
try {
const containerName = decodeURIComponent(params.containerName);
const envIdParam = url.searchParams.get('env');
const envId = envIdParam ? parseInt(envIdParam) : undefined;
// Get the setting ID before deleting
const setting = await getAutoUpdateSetting(containerName, envId);
const settingId = setting?.id;
const deleted = await deleteAutoUpdateSetting(containerName, envId);
// Unregister schedule from croner
if (deleted && settingId) {
unregisterSchedule(settingId, 'container_update');
}
return json({ success: deleted });
} catch (error) {
console.error('Failed to delete auto-update setting:', error);
return json({ error: 'Failed to delete auto-update setting' }, { status: 500 });
}
};
+463
View File
@@ -0,0 +1,463 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import {
startContainer,
stopContainer,
restartContainer,
pauseContainer,
unpauseContainer,
removeContainer,
inspectContainer,
listContainers,
removeImage,
removeVolume,
removeNetwork
} from '$lib/server/docker';
import {
startStack,
stopStack,
restartStack,
downStack,
removeStack
} from '$lib/server/stacks';
import { deleteAutoUpdateSchedule, getAutoUpdateSetting } from '$lib/server/db';
import { unregisterSchedule } from '$lib/server/scheduler';
// SSE Event types
export type BatchEventType = 'start' | 'progress' | 'complete' | 'error';
export type ItemStatus = 'pending' | 'processing' | 'success' | 'error';
export interface BatchStartEvent {
type: 'start';
total: number;
}
export interface BatchProgressEvent {
type: 'progress';
id: string;
name: string;
status: ItemStatus;
message?: string;
error?: string;
current: number;
total: number;
}
export interface BatchCompleteEvent {
type: 'complete';
summary: {
total: number;
success: number;
failed: number;
};
}
export interface BatchErrorEvent {
type: 'error';
error: string;
}
export type BatchEvent = BatchStartEvent | BatchProgressEvent | BatchCompleteEvent | BatchErrorEvent;
// Supported operations per entity type
const ENTITY_OPERATIONS: Record<string, string[]> = {
containers: ['start', 'stop', 'restart', 'pause', 'unpause', 'remove'],
images: ['remove'],
volumes: ['remove'],
networks: ['remove'],
stacks: ['start', 'stop', 'restart', 'down', 'remove']
};
// Permission mapping for entity operations
const PERMISSION_MAP: Record<string, Record<string, string>> = {
containers: {
start: 'start',
stop: 'stop',
restart: 'restart',
pause: 'stop',
unpause: 'start',
remove: 'remove'
},
images: { remove: 'remove' },
volumes: { remove: 'remove' },
networks: { remove: 'remove' },
stacks: {
start: 'start',
stop: 'stop',
restart: 'restart',
down: 'stop',
remove: 'remove'
}
};
interface BatchRequest {
operation: string;
entityType: string;
items: Array<{ id: string; name: string }>;
options?: {
force?: boolean;
removeVolumes?: boolean;
};
}
// Concurrent execution helper with controlled parallelism
async function processWithConcurrency<T>(
items: T[],
concurrency: number,
processor: (item: T, index: number) => Promise<void>,
signal: AbortSignal
): Promise<void> {
let currentIndex = 0;
const total = items.length;
async function processNext(): Promise<void> {
while (currentIndex < total) {
if (signal.aborted) return;
const index = currentIndex++;
await processor(items[index], index);
}
}
// Start 'concurrency' number of workers
const workers = Array(Math.min(concurrency, total))
.fill(null)
.map(() => processNext());
await Promise.all(workers);
}
/**
* Unified batch operations endpoint with SSE streaming.
* Handles bulk operations for containers, images, volumes, networks, and stacks.
*/
export const POST: RequestHandler = async ({ url, cookies, request }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Parse request body
let body: BatchRequest;
try {
body = await request.json();
} catch {
return json({ error: 'Invalid JSON body' }, { status: 400 });
}
const { operation, entityType, items, options = {} } = body;
// Validate entity type
if (!ENTITY_OPERATIONS[entityType]) {
return json(
{ error: `Invalid entity type: ${entityType}. Supported: ${Object.keys(ENTITY_OPERATIONS).join(', ')}` },
{ status: 400 }
);
}
// Validate operation for entity type
if (!ENTITY_OPERATIONS[entityType].includes(operation)) {
return json(
{ error: `Invalid operation '${operation}' for ${entityType}. Supported: ${ENTITY_OPERATIONS[entityType].join(', ')}` },
{ status: 400 }
);
}
// Validate items
if (!items || !Array.isArray(items) || items.length === 0) {
return json({ error: 'items array is required and must not be empty' }, { status: 400 });
}
// Permission check
const permissionAction = PERMISSION_MAP[entityType][operation];
if (auth.authEnabled && !(await auth.can(entityType as any, permissionAction, envIdNum))) {
return json({ error: 'Permission denied' }, { status: 403 });
}
// Environment access check (enterprise only)
if (envIdNum && auth.isEnterprise && !(await auth.canAccessEnvironment(envIdNum))) {
return json({ error: 'Access denied to this environment' }, { status: 403 });
}
// Check if audit is needed (enterprise only)
const needsAudit = auth.isEnterprise;
// Create abort controller for cancellation
const abortController = new AbortController();
const encoder = new TextEncoder();
let controllerClosed = false;
let keepaliveInterval: ReturnType<typeof setInterval> | null = null;
const stream = new ReadableStream({
async start(controller) {
const safeEnqueue = (data: BatchEvent) => {
if (!controllerClosed) {
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
} catch {
controllerClosed = true;
abortController.abort();
}
}
};
// Send SSE keepalive comments every 5s
keepaliveInterval = setInterval(() => {
if (controllerClosed) return;
try {
controller.enqueue(encoder.encode(`: keepalive\n\n`));
} catch {
controllerClosed = true;
abortController.abort();
}
}, 5000);
let successCount = 0;
let failCount = 0;
// Send start event
safeEnqueue({
type: 'start',
total: items.length
});
// Process items with concurrency of 3
await processWithConcurrency(
items,
3,
async (item, index) => {
if (abortController.signal.aborted) return;
const { id, name } = item;
// Send processing status
safeEnqueue({
type: 'progress',
id,
name,
status: 'processing',
current: index + 1,
total: items.length
});
try {
await executeOperation(entityType, operation, id, name, envIdNum, options, needsAudit);
safeEnqueue({
type: 'progress',
id,
name,
status: 'success',
current: index + 1,
total: items.length
});
successCount++;
} catch (error: any) {
safeEnqueue({
type: 'progress',
id,
name,
status: 'error',
error: error.message || 'Unknown error',
current: index + 1,
total: items.length
});
failCount++;
}
},
abortController.signal
);
// Send complete event
safeEnqueue({
type: 'complete',
summary: {
total: items.length,
success: successCount,
failed: failCount
}
});
if (keepaliveInterval) {
clearInterval(keepaliveInterval);
}
controller.close();
},
cancel() {
controllerClosed = true;
abortController.abort();
if (keepaliveInterval) {
clearInterval(keepaliveInterval);
}
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
}
});
};
/**
* Execute a single operation on an entity.
* Centralized operation execution to keep code DRY.
*/
async function executeOperation(
entityType: string,
operation: string,
id: string,
name: string,
envIdNum: number | undefined,
options: { force?: boolean; removeVolumes?: boolean },
needsAudit: boolean
): Promise<void> {
switch (entityType) {
case 'containers':
await executeContainerOperation(operation, id, name, envIdNum, options, needsAudit);
break;
case 'images':
await executeImageOperation(operation, id, envIdNum, options);
break;
case 'volumes':
await executeVolumeOperation(operation, id, envIdNum, options);
break;
case 'networks':
await executeNetworkOperation(operation, id, envIdNum);
break;
case 'stacks':
await executeStackOperation(operation, id, envIdNum, options);
break;
default:
throw new Error(`Unsupported entity type: ${entityType}`);
}
}
async function executeContainerOperation(
operation: string,
id: string,
name: string,
envIdNum: number | undefined,
options: { force?: boolean },
needsAudit: boolean
): Promise<void> {
switch (operation) {
case 'start':
await startContainer(id, envIdNum);
break;
case 'stop':
await stopContainer(id, envIdNum);
break;
case 'restart':
await restartContainer(id, envIdNum);
break;
case 'pause':
await pauseContainer(id, envIdNum);
break;
case 'unpause':
await unpauseContainer(id, envIdNum);
break;
case 'remove':
// In free edition, skip inspect (no audit needed)
// In enterprise, we might want to audit but for batch ops we skip for performance
await removeContainer(id, options.force ?? true, envIdNum);
// Clean up auto-update schedule if exists
try {
const setting = await getAutoUpdateSetting(name, envIdNum);
if (setting) {
unregisterSchedule(setting.id, 'container_update');
await deleteAutoUpdateSchedule(name, envIdNum);
}
} catch {
// Ignore cleanup errors
}
break;
default:
throw new Error(`Unsupported container operation: ${operation}`);
}
}
async function executeImageOperation(
operation: string,
id: string,
envIdNum: number | undefined,
options: { force?: boolean }
): Promise<void> {
switch (operation) {
case 'remove':
await removeImage(id, options.force ?? false, envIdNum);
break;
default:
throw new Error(`Unsupported image operation: ${operation}`);
}
}
async function executeVolumeOperation(
operation: string,
name: string,
envIdNum: number | undefined,
options: { force?: boolean }
): Promise<void> {
switch (operation) {
case 'remove':
await removeVolume(name, options.force ?? false, envIdNum);
break;
default:
throw new Error(`Unsupported volume operation: ${operation}`);
}
}
async function executeNetworkOperation(
operation: string,
id: string,
envIdNum: number | undefined
): Promise<void> {
switch (operation) {
case 'remove':
await removeNetwork(id, envIdNum);
break;
default:
throw new Error(`Unsupported network operation: ${operation}`);
}
}
async function executeStackOperation(
operation: string,
name: string,
envIdNum: number | undefined,
options: { removeVolumes?: boolean; force?: boolean }
): Promise<void> {
switch (operation) {
case 'start': {
const result = await startStack(name, envIdNum);
if (!result.success) throw new Error(result.error || 'Failed to start stack');
break;
}
case 'stop': {
const result = await stopStack(name, envIdNum);
if (!result.success) throw new Error(result.error || 'Failed to stop stack');
break;
}
case 'restart': {
const result = await restartStack(name, envIdNum);
if (!result.success) throw new Error(result.error || 'Failed to restart stack');
break;
}
case 'down': {
const result = await downStack(name, envIdNum, options.removeVolumes ?? false);
if (!result.success) throw new Error(result.error || 'Failed to down stack');
break;
}
case 'remove': {
const result = await removeStack(name, envIdNum, options.force ?? false);
if (!result.success) throw new Error(result.error || 'Failed to remove stack');
break;
}
default:
throw new Error(`Unsupported stack operation: ${operation}`);
}
}
+7
View File
@@ -0,0 +1,7 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import changelog from '$lib/data/changelog.json';
export const GET: RequestHandler = async () => {
return json(changelog);
};
+53
View File
@@ -0,0 +1,53 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getConfigSets, createConfigSet } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('configsets', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const configSets = await getConfigSets();
return json(configSets);
} catch (error) {
console.error('Failed to fetch config sets:', error);
return json({ error: 'Failed to fetch config sets' }, { status: 500 });
}
};
export const POST: RequestHandler = async ({ request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('configsets', 'create')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const body = await request.json();
if (!body.name?.trim()) {
return json({ error: 'Name is required' }, { status: 400 });
}
const configSet = await createConfigSet({
name: body.name.trim(),
description: body.description?.trim() || undefined,
envVars: body.envVars || [],
labels: body.labels || [],
ports: body.ports || [],
volumes: body.volumes || [],
networkMode: body.networkMode || 'bridge',
restartPolicy: body.restartPolicy || 'no'
});
return json(configSet, { status: 201 });
} catch (error: any) {
console.error('Failed to create config set:', error);
if (error.message?.includes('UNIQUE constraint')) {
return json({ error: 'A config set with this name already exists' }, { status: 400 });
}
return json({ error: 'Failed to create config set' }, { status: 500 });
}
};
@@ -0,0 +1,91 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getConfigSet, updateConfigSet, deleteConfigSet } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('configsets', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const id = parseInt(params.id);
if (isNaN(id)) {
return json({ error: 'Invalid ID' }, { status: 400 });
}
const configSet = await getConfigSet(id);
if (!configSet) {
return json({ error: 'Config set not found' }, { status: 404 });
}
return json(configSet);
} catch (error) {
console.error('Failed to fetch config set:', error);
return json({ error: 'Failed to fetch config set' }, { status: 500 });
}
};
export const PUT: RequestHandler = async ({ params, request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('configsets', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const id = parseInt(params.id);
if (isNaN(id)) {
return json({ error: 'Invalid ID' }, { status: 400 });
}
const body = await request.json();
const configSet = await updateConfigSet(id, {
name: body.name?.trim(),
description: body.description?.trim(),
envVars: body.envVars,
labels: body.labels,
ports: body.ports,
volumes: body.volumes,
networkMode: body.networkMode,
restartPolicy: body.restartPolicy
});
if (!configSet) {
return json({ error: 'Config set not found' }, { status: 404 });
}
return json(configSet);
} catch (error: any) {
console.error('Failed to update config set:', error);
if (error.message?.includes('UNIQUE constraint')) {
return json({ error: 'A config set with this name already exists' }, { status: 400 });
}
return json({ error: 'Failed to update config set' }, { status: 500 });
}
};
export const DELETE: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('configsets', 'delete')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const id = parseInt(params.id);
if (isNaN(id)) {
return json({ error: 'Invalid ID' }, { status: 400 });
}
const deleted = await deleteConfigSet(id);
if (!deleted) {
return json({ error: 'Config set not found' }, { status: 404 });
}
return json({ success: true });
} catch (error) {
console.error('Failed to delete config set:', error);
return json({ error: 'Failed to delete config set' }, { status: 500 });
}
};
+123
View File
@@ -0,0 +1,123 @@
import { json } from '@sveltejs/kit';
import { listContainers, createContainer, pullImage, EnvironmentNotFoundError, type CreateContainerOptions } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import { hasEnvironments } from '$lib/server/db';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
const all = url.searchParams.get('all') !== 'false';
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
// Environment access check (enterprise only)
if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) {
return json({ error: 'Access denied to this environment' }, { status: 403 });
}
// Early return if no environments configured (fresh install)
if (!await hasEnvironments()) {
return json([]);
}
// Early return if no environment specified
if (!envIdNum) {
return json([]);
}
try {
const containers = await listContainers(all, envIdNum);
return json(containers);
} catch (error: any) {
// Return 404 for missing environment so frontend can clear stale localStorage
if (error instanceof EnvironmentNotFoundError) {
return json({ error: 'Environment not found' }, { status: 404 });
}
console.error('Error listing containers:', error);
// Return empty array instead of error to allow UI to load
return json([]);
}
};
export const POST: RequestHandler = async (event) => {
const { request, url, cookies } = event;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'create', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
// Environment access check (enterprise only)
if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) {
return json({ error: 'Access denied to this environment' }, { status: 403 });
}
try {
const body = await request.json();
const { startAfterCreate, ...options } = body;
// Check if image needs to be pulled
try {
console.log(`Attempting to create container with image: ${options.image}`);
const container = await createContainer(options, envIdNum);
// Start the container if requested
if (startAfterCreate) {
await container.start();
}
// Audit log
await auditContainer(event, 'create', container.id, options.name, envIdNum, { image: options.image });
return json({ success: true, id: container.id });
} catch (createError: any) {
// If error is due to missing image, try to pull it first
if (createError.statusCode === 404 && createError.json?.message?.includes('No such image')) {
console.log(`Image ${options.image} not found locally. Pulling...`);
try {
// Pull the image
await pullImage(options.image, undefined, envIdNum);
console.log(`Successfully pulled image: ${options.image}`);
// Retry creating the container
const container = await createContainer(options, envIdNum);
// Start the container if requested
if (startAfterCreate) {
await container.start();
}
// Audit log
await auditContainer(event, 'create', container.id, options.name, envIdNum, { image: options.image, imagePulled: true });
return json({ success: true, id: container.id, imagePulled: true });
} catch (pullError) {
console.error('Error pulling image:', pullError);
return json({
error: 'Failed to pull image',
details: `Could not pull image ${options.image}: ${String(pullError)}`
}, { status: 500 });
}
}
// If it's a different error, rethrow it
throw createError;
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`[Container] Create failed: ${message}`);
return json({ error: 'Failed to create container', details: message }, { status: 500 });
}
};
+93
View File
@@ -0,0 +1,93 @@
import { json } from '@sveltejs/kit';
import {
inspectContainer,
removeContainer,
getContainerLogs
} from '$lib/server/docker';
import { deleteAutoUpdateSchedule, getAutoUpdateSetting } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import { unregisterSchedule } from '$lib/server/scheduler';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
// Environment access check (enterprise only)
if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) {
return json({ error: 'Access denied to this environment' }, { status: 403 });
}
try {
const details = await inspectContainer(params.id, envIdNum);
return json(details);
} catch (error) {
console.error('Error inspecting container:', error);
return json({ error: 'Failed to inspect container' }, { status: 500 });
}
};
export const DELETE: RequestHandler = async (event) => {
const { params, url, cookies } = event;
const auth = await authorize(cookies);
const force = url.searchParams.get('force') === 'true';
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'remove', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
// Environment access check (enterprise only)
if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) {
return json({ error: 'Access denied to this environment' }, { status: 403 });
}
try {
// Get container name before deletion for audit
let containerName = params.id;
try {
const details = await inspectContainer(params.id, envIdNum);
containerName = details.Name?.replace(/^\//, '') || params.id;
} catch {
// Container might not exist or other error, use ID
}
await removeContainer(params.id, force, envIdNum);
// Audit log
await auditContainer(event, 'delete', params.id, containerName, envIdNum, { force });
// Clean up auto-update schedule if exists
try {
// Get the schedule ID before deleting
const setting = await getAutoUpdateSetting(containerName, envIdNum);
if (setting) {
// Unregister from croner
unregisterSchedule(setting.id, 'container_update');
// Delete from database
await deleteAutoUpdateSchedule(containerName, envIdNum);
}
} catch (error) {
console.error('Failed to cleanup auto-update schedule:', error);
// Don't fail the deletion if schedule cleanup fails
}
return json({ success: true });
} catch (error) {
console.error('Error removing container:', error);
return json({ error: 'Failed to remove container' }, { status: 500 });
}
};
@@ -0,0 +1,59 @@
/**
* Container Exec API
*
* POST: Creates an exec instance for terminal attachment
* Returns exec ID that can be used for WebSocket connection
*/
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { createExec, getDockerConnectionInfo } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
export const POST: RequestHandler = async ({ params, request, cookies, url }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !auth.isAuthenticated) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const containerId = params.id;
const envIdParam = url.searchParams.get('envId');
const envId = envIdParam ? parseInt(envIdParam, 10) : undefined;
// Permission check with environment context
if (!await auth.can('containers', 'exec', envId)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const body = await request.json().catch(() => ({}));
const shell = body.shell || '/bin/sh';
const user = body.user || 'root';
// Create exec instance
const exec = await createExec({
containerId,
cmd: [shell],
user,
envId
});
// Get connection info for the frontend
const connectionInfo = await getDockerConnectionInfo(envId);
return json({
execId: exec.Id,
connectionInfo: {
type: connectionInfo.type,
host: connectionInfo.host,
port: connectionInfo.port
}
});
} catch (error: any) {
console.error('Failed to create exec:', error);
return json(
{ error: error.message || 'Failed to create exec instance' },
{ status: 500 }
);
}
};
@@ -0,0 +1,32 @@
import { json } from '@sveltejs/kit';
import { listContainerDirectory } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
const path = url.searchParams.get('path') || '/';
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
const simpleLs = url.searchParams.get('simpleLs') === 'true';
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const result = await listContainerDirectory(
params.id,
path,
envIdNum,
simpleLs
);
return json(result);
} catch (error: any) {
console.error('Error listing container directory:', error);
return json({ error: error.message || 'Failed to list directory' }, { status: 500 });
}
};
@@ -0,0 +1,57 @@
import { json } from '@sveltejs/kit';
import { chmodContainerPath } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ params, url, cookies, request }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'exec', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const body = await request.json();
const { path, mode, recursive } = body;
if (!path || typeof path !== 'string') {
return json({ error: 'Path is required' }, { status: 400 });
}
if (!mode || typeof mode !== 'string') {
return json({ error: 'Mode is required (e.g., "755" or "u+x")' }, { status: 400 });
}
await chmodContainerPath(params.id, path, mode, recursive === true, envIdNum);
return json({ success: true, path, mode, recursive: recursive === true });
} catch (error: any) {
console.error('Error changing permissions:', error);
const msg = error.message || String(error);
if (msg.includes('Permission denied')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
if (msg.includes('No such file or directory')) {
return json({ error: 'Path not found' }, { status: 404 });
}
if (msg.includes('Invalid chmod mode')) {
return json({ error: msg }, { status: 400 });
}
if (msg.includes('Read-only file system')) {
return json({ error: 'File system is read-only' }, { status: 403 });
}
if (msg.includes('Operation not permitted')) {
return json({ error: 'Operation not permitted' }, { status: 403 });
}
if (msg.includes('container is not running')) {
return json({ error: 'Container is not running' }, { status: 400 });
}
return json({ error: `Failed to change permissions: ${msg}` }, { status: 500 });
}
};
@@ -0,0 +1,116 @@
import { json } from '@sveltejs/kit';
import { readContainerFile, writeContainerFile } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import type { RequestHandler } from './$types';
// Max file size for reading (1MB)
const MAX_FILE_SIZE = 1024 * 1024;
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
const path = url.searchParams.get('path');
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
if (!path) {
return json({ error: 'Path is required' }, { status: 400 });
}
const content = await readContainerFile(
params.id,
path,
envIdNum
);
// Check if content is too large
if (content.length > MAX_FILE_SIZE) {
return json({ error: 'File is too large to edit (max 1MB)' }, { status: 413 });
}
return json({ content, path });
} catch (error: any) {
console.error('Error reading container file:', error);
const msg = error.message || String(error);
if (msg.includes('No such file or directory')) {
return json({ error: 'File not found' }, { status: 404 });
}
if (msg.includes('Permission denied')) {
return json({ error: 'Permission denied to read this file' }, { status: 403 });
}
if (msg.includes('Is a directory')) {
return json({ error: 'Cannot read a directory' }, { status: 400 });
}
if (msg.includes('container is not running')) {
return json({ error: 'Container is not running' }, { status: 400 });
}
return json({ error: `Failed to read file: ${msg}` }, { status: 500 });
}
};
export const PUT: RequestHandler = async ({ params, url, cookies, request }) => {
const auth = await authorize(cookies);
const path = url.searchParams.get('path');
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'exec', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
if (!path) {
return json({ error: 'Path is required' }, { status: 400 });
}
const body = await request.json();
if (typeof body.content !== 'string') {
return json({ error: 'Content is required' }, { status: 400 });
}
// Check content size
if (body.content.length > MAX_FILE_SIZE) {
return json({ error: 'Content is too large (max 1MB)' }, { status: 413 });
}
await writeContainerFile(
params.id,
path,
body.content,
envIdNum
);
return json({ success: true, path });
} catch (error: any) {
console.error('Error writing container file:', error);
const msg = error.message || String(error);
if (msg.includes('Permission denied')) {
return json({ error: 'Permission denied to write this file' }, { status: 403 });
}
if (msg.includes('No such file or directory')) {
return json({ error: 'Directory not found' }, { status: 404 });
}
if (msg.includes('Read-only file system')) {
return json({ error: 'File system is read-only' }, { status: 403 });
}
if (msg.includes('No space left on device')) {
return json({ error: 'No space left on device' }, { status: 507 });
}
if (msg.includes('container is not running')) {
return json({ error: 'Container is not running' }, { status: 400 });
}
return json({ error: `Failed to write file: ${msg}` }, { status: 500 });
}
};
@@ -0,0 +1,55 @@
import { json } from '@sveltejs/kit';
import { createContainerFile, createContainerDirectory } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ params, url, cookies, request }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'exec', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const body = await request.json();
const { path, type } = body;
if (!path || typeof path !== 'string') {
return json({ error: 'Path is required' }, { status: 400 });
}
if (type !== 'file' && type !== 'directory') {
return json({ error: 'Type must be "file" or "directory"' }, { status: 400 });
}
if (type === 'file') {
await createContainerFile(params.id, path, envIdNum);
} else {
await createContainerDirectory(params.id, path, envIdNum);
}
return json({ success: true, path, type });
} catch (error: any) {
console.error('Error creating path:', error);
const msg = error.message || String(error);
if (msg.includes('Permission denied')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
if (msg.includes('File exists')) {
return json({ error: 'Path already exists' }, { status: 409 });
}
if (msg.includes('No such file or directory')) {
return json({ error: 'Parent directory not found' }, { status: 404 });
}
if (msg.includes('container is not running')) {
return json({ error: 'Container is not running' }, { status: 400 });
}
return json({ error: `Failed to create: ${msg}` }, { status: 500 });
}
};
@@ -0,0 +1,51 @@
import { json } from '@sveltejs/kit';
import { deleteContainerPath } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import type { RequestHandler } from './$types';
export const DELETE: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
const path = url.searchParams.get('path');
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'exec', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
if (!path) {
return json({ error: 'Path is required' }, { status: 400 });
}
await deleteContainerPath(params.id, path, envIdNum);
return json({ success: true, path });
} catch (error: any) {
console.error('Error deleting path:', error);
const msg = error.message || String(error);
if (msg.includes('Permission denied')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
if (msg.includes('No such file or directory')) {
return json({ error: 'Path not found' }, { status: 404 });
}
if (msg.includes('Cannot delete critical')) {
return json({ error: msg }, { status: 400 });
}
if (msg.includes('Read-only file system')) {
return json({ error: 'File system is read-only' }, { status: 403 });
}
if (msg.includes('Directory not empty')) {
return json({ error: 'Directory is not empty' }, { status: 400 });
}
if (msg.includes('container is not running')) {
return json({ error: 'Container is not running' }, { status: 400 });
}
return json({ error: `Failed to delete: ${msg}` }, { status: 500 });
}
};
@@ -0,0 +1,98 @@
import { getContainerArchive, statContainerPath } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
const path = url.searchParams.get('path');
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) {
return new Response(JSON.stringify({ error: 'Permission denied' }), {
status: 403,
headers: { 'Content-Type': 'application/json' }
});
}
if (!path) {
return new Response(JSON.stringify({ error: 'Path is required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
try {
// Get format from query parameter (defaults to tar)
const format = url.searchParams.get('format') || 'tar';
// Get stat info to determine filename
let filename: string;
try {
const stat = await statContainerPath(params.id, path, envIdNum);
filename = stat.name || path.split('/').pop() || 'download';
} catch {
filename = path.split('/').pop() || 'download';
}
// Get the archive from Docker
const response = await getContainerArchive(
params.id,
path,
envIdNum
);
// Prepare response based on format
let body: ReadableStream<Uint8Array> | Uint8Array = response.body!;
let contentType = 'application/x-tar';
let extension = '.tar';
if (format === 'tar.gz') {
// Compress with gzip using Bun's native implementation
const tarData = new Uint8Array(await response.arrayBuffer());
body = Bun.gzipSync(tarData);
contentType = 'application/gzip';
extension = '.tar.gz';
}
const headers: Record<string, string> = {
'Content-Type': contentType,
'Content-Disposition': `attachment; filename="${filename}${extension}"`
};
// Set content length for compressed data
if (body instanceof Uint8Array) {
headers['Content-Length'] = body.length.toString();
} else {
// Pass through content length for streaming tar
const contentLength = response.headers.get('Content-Length');
if (contentLength) {
headers['Content-Length'] = contentLength;
}
}
return new Response(body, { headers });
} catch (error: any) {
console.error('Error downloading container file:', error);
if (error.message?.includes('No such file or directory')) {
return new Response(JSON.stringify({ error: 'File not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' }
});
}
if (error.message?.includes('Permission denied')) {
return new Response(JSON.stringify({ error: 'Permission denied to access this path' }), {
status: 403,
headers: { 'Content-Type': 'application/json' }
});
}
return new Response(JSON.stringify({ error: 'Failed to download file' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
};
@@ -0,0 +1,54 @@
import { json } from '@sveltejs/kit';
import { renameContainerPath } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ params, url, cookies, request }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'exec', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const body = await request.json();
const { oldPath, newPath } = body;
if (!oldPath || typeof oldPath !== 'string') {
return json({ error: 'Old path is required' }, { status: 400 });
}
if (!newPath || typeof newPath !== 'string') {
return json({ error: 'New path is required' }, { status: 400 });
}
await renameContainerPath(params.id, oldPath, newPath, envIdNum);
return json({ success: true, oldPath, newPath });
} catch (error: any) {
console.error('Error renaming path:', error);
const msg = error.message || String(error);
if (msg.includes('Permission denied')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
if (msg.includes('No such file or directory')) {
return json({ error: 'Source path not found' }, { status: 404 });
}
if (msg.includes('File exists') || msg.includes('Directory not empty')) {
return json({ error: 'Destination already exists' }, { status: 409 });
}
if (msg.includes('Read-only file system')) {
return json({ error: 'File system is read-only' }, { status: 403 });
}
if (msg.includes('container is not running')) {
return json({ error: 'Container is not running' }, { status: 400 });
}
return json({ error: `Failed to rename: ${msg}` }, { status: 500 });
}
};
@@ -0,0 +1,154 @@
import { json } from '@sveltejs/kit';
import { putContainerArchive } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import type { RequestHandler } from './$types';
/**
* Create a simple tar archive from a single file
* TAR format: 512-byte header followed by file content padded to 512 bytes
*/
function createTarArchive(filename: string, content: Uint8Array): Uint8Array {
// TAR header is 512 bytes
const header = new Uint8Array(512);
const encoder = new TextEncoder();
// File name (100 bytes)
const nameBytes = encoder.encode(filename.slice(0, 99));
header.set(nameBytes, 0);
// File mode (8 bytes) - 0644
header.set(encoder.encode('0000644\0'), 100);
// Owner UID (8 bytes)
header.set(encoder.encode('0000000\0'), 108);
// Owner GID (8 bytes)
header.set(encoder.encode('0000000\0'), 116);
// File size in octal (12 bytes)
const sizeOctal = content.length.toString(8).padStart(11, '0');
header.set(encoder.encode(sizeOctal + '\0'), 124);
// Modification time (12 bytes) - current time in octal
const mtime = Math.floor(Date.now() / 1000).toString(8).padStart(11, '0');
header.set(encoder.encode(mtime + '\0'), 136);
// Checksum placeholder (8 spaces initially)
header.set(encoder.encode(' '), 148);
// Type flag - '0' for regular file
header[156] = 48; // '0'
// Link name (100 bytes) - empty
// Magic (6 bytes) - 'ustar\0'
header.set(encoder.encode('ustar\0'), 257);
// Version (2 bytes) - '00'
header.set(encoder.encode('00'), 263);
// Owner name (32 bytes)
header.set(encoder.encode('root'), 265);
// Group name (32 bytes)
header.set(encoder.encode('root'), 297);
// Calculate checksum
let checksum = 0;
for (let i = 0; i < 512; i++) {
checksum += header[i];
}
const checksumOctal = checksum.toString(8).padStart(6, '0') + '\0 ';
header.set(encoder.encode(checksumOctal), 148);
// Calculate padding to 512-byte boundary
const paddingSize = (512 - (content.length % 512)) % 512;
const padding = new Uint8Array(paddingSize);
// End of archive marker (two 512-byte zero blocks)
const endMarker = new Uint8Array(1024);
// Combine all parts
const totalSize = header.length + content.length + paddingSize + endMarker.length;
const tar = new Uint8Array(totalSize);
let offset = 0;
tar.set(header, offset);
offset += header.length;
tar.set(content, offset);
offset += content.length;
tar.set(padding, offset);
offset += paddingSize;
tar.set(endMarker, offset);
return tar;
}
export const POST: RequestHandler = async ({ params, url, request, cookies }) => {
const auth = await authorize(cookies);
const path = url.searchParams.get('path');
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'exec', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
if (!path) {
return json({ error: 'Target path is required' }, { status: 400 });
}
try {
const formData = await request.formData();
const files = formData.getAll('files') as File[];
if (files.length === 0) {
return json({ error: 'No files provided' }, { status: 400 });
}
// For simplicity, we'll upload files one at a time
// A more sophisticated implementation could pack multiple files into one tar
const uploaded: string[] = [];
const errors: string[] = [];
for (const file of files) {
try {
const content = new Uint8Array(await file.arrayBuffer());
const tar = createTarArchive(file.name, content);
await putContainerArchive(
params.id,
path,
tar,
envId ? parseInt(envId) : undefined
);
uploaded.push(file.name);
} catch (err: any) {
errors.push(`${file.name}: ${err.message}`);
}
}
if (errors.length > 0 && uploaded.length === 0) {
return json({ error: 'Failed to upload files', details: errors }, { status: 500 });
}
return json({
success: true,
uploaded,
errors: errors.length > 0 ? errors : undefined
});
} catch (error: any) {
console.error('Error uploading to container:', error);
if (error.message?.includes('Permission denied')) {
return json({ error: 'Permission denied to write to this path' }, { status: 403 });
}
if (error.message?.includes('No such file or directory')) {
return json({ error: 'Target directory not found' }, { status: 404 });
}
return json({ error: 'Failed to upload files' }, { status: 500 });
}
};
@@ -0,0 +1,24 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { inspectContainer } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'inspect', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const containerData = await inspectContainer(params.id, envIdNum);
return json(containerData);
} catch (error) {
console.error('Failed to inspect container:', error);
return json({ error: 'Failed to inspect container' }, { status: 500 });
}
};
@@ -0,0 +1,25 @@
import { json } from '@sveltejs/kit';
import { getContainerLogs } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
const tail = parseInt(url.searchParams.get('tail') || '100');
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'logs', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const logs = await getContainerLogs(params.id, tail, envIdNum);
return json({ logs });
} catch (error: any) {
console.error('Error getting container logs:', error?.message || error, error?.stack);
return json({ error: 'Failed to get container logs', details: error?.message }, { status: 500 });
}
};
@@ -0,0 +1,452 @@
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { getEnvironment } from '$lib/server/db';
import { sendEdgeRequest, sendEdgeStreamRequest, isEdgeConnected } from '$lib/server/hawser';
import { existsSync } from 'node:fs';
import { homedir } from 'node:os';
// Detect Docker socket path
function detectDockerSocket(): string {
if (process.env.DOCKER_SOCKET && existsSync(process.env.DOCKER_SOCKET)) {
return process.env.DOCKER_SOCKET;
}
if (process.env.DOCKER_HOST?.startsWith('unix://')) {
const socketPath = process.env.DOCKER_HOST.replace('unix://', '');
if (existsSync(socketPath)) return socketPath;
}
const possibleSockets = [
'/var/run/docker.sock',
`${homedir()}/.docker/run/docker.sock`,
`${homedir()}/.orbstack/run/docker.sock`,
'/run/docker.sock'
];
for (const socket of possibleSockets) {
if (existsSync(socket)) return socket;
}
return '/var/run/docker.sock';
}
const socketPath = detectDockerSocket();
interface DockerClientConfig {
type: 'socket' | 'http' | 'https' | 'hawser-edge';
socketPath?: string;
host?: string;
port?: number;
ca?: string;
cert?: string;
key?: string;
hawserToken?: string;
environmentId?: number;
}
async function getDockerConfig(envId?: number | null): Promise<DockerClientConfig | null> {
if (!envId) {
return null;
}
const env = await getEnvironment(envId);
if (!env) {
return null;
}
if (env.connectionType === 'socket' || !env.connectionType) {
return { type: 'socket', socketPath: env.socketPath || socketPath };
}
if (env.connectionType === 'hawser-edge') {
return { type: 'hawser-edge', environmentId: envId };
}
const protocol = (env.protocol as 'http' | 'https') || 'http';
return {
type: protocol,
host: env.host || 'localhost',
port: env.port || 2375,
ca: env.tlsCa || undefined,
cert: env.tlsCert || undefined,
key: env.tlsKey || undefined,
hawserToken: env.connectionType === 'hawser-standard' ? env.hawserToken || undefined : undefined
};
}
/**
* Demultiplex Docker stream frame - returns payload and stream type
*/
function parseDockerFrame(buffer: Buffer, offset: number): { type: number; size: number; payload: string } | null {
if (buffer.length < offset + 8) return null;
const streamType = buffer.readUInt8(offset);
const frameSize = buffer.readUInt32BE(offset + 4);
if (buffer.length < offset + 8 + frameSize) return null;
const payload = buffer.slice(offset + 8, offset + 8 + frameSize).toString('utf-8');
return { type: streamType, size: 8 + frameSize, payload };
}
/**
* Handle logs streaming for Hawser Edge connections
*/
async function handleEdgeLogsStream(containerId: string, tail: string, environmentId: number): Promise<Response> {
// Check if edge agent is connected
if (!isEdgeConnected(environmentId)) {
return new Response(JSON.stringify({ error: 'Edge agent not connected' }), {
status: 503,
headers: { 'Content-Type': 'application/json' }
});
}
// First, check if container has TTY enabled and get container name
let hasTty = false;
let containerName = containerId.substring(0, 12); // Default to short ID
try {
const inspectPath = `/containers/${containerId}/json`;
const inspectResponse = await sendEdgeRequest(environmentId, 'GET', inspectPath);
if (inspectResponse.statusCode === 200) {
const info = JSON.parse(inspectResponse.body as string);
hasTty = info.Config?.Tty ?? false;
// Get container name (strip leading /)
containerName = info.Name?.replace(/^\//, '') || containerName;
}
} catch {
// Ignore - default to demux mode
}
const logsPath = `/containers/${containerId}/logs?stdout=true&stderr=true&follow=true&tail=${tail}&timestamps=true`;
let controllerClosed = false;
let cancelStream: (() => void) | null = null;
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
const stream = new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
const safeEnqueue = (data: string) => {
if (!controllerClosed) {
try {
controller.enqueue(encoder.encode(data));
} catch {
controllerClosed = true;
}
}
};
// Send heartbeat to keep connection alive (every 5s for Traefik)
heartbeatInterval = setInterval(() => {
safeEnqueue(`: keepalive\n\n`);
}, 5000);
// Buffer for non-TTY stream demuxing
let buffer = Buffer.alloc(0);
// Send connected event
safeEnqueue(`event: connected\ndata: ${JSON.stringify({ containerId, containerName, hasTty })}\n\n`);
// Start streaming logs via Edge
const { cancel } = sendEdgeStreamRequest(
environmentId,
'GET',
logsPath,
{
onData: (data: string, streamType?: 'stdout' | 'stderr') => {
if (controllerClosed) return;
if (hasTty) {
// TTY mode: data is raw text, may be base64 encoded
let text = data;
try {
// Try to decode as base64
text = Buffer.from(data, 'base64').toString('utf-8');
} catch {
// Not base64, use as-is
}
if (text) {
safeEnqueue(`event: log\ndata: ${JSON.stringify({ text, containerName })}\n\n`);
}
} else {
// Non-TTY mode: data might be base64 encoded Docker multiplexed stream
let rawData: Buffer;
try {
rawData = Buffer.from(data, 'base64');
} catch {
rawData = Buffer.from(data, 'utf-8');
}
buffer = Buffer.concat([buffer, rawData]);
// Process complete frames
let offset = 0;
while (true) {
const frame = parseDockerFrame(buffer, offset);
if (!frame) break;
if (frame.payload) {
safeEnqueue(`event: log\ndata: ${JSON.stringify({
text: frame.payload,
containerName,
stream: frame.type === 2 ? 'stderr' : 'stdout'
})}\n\n`);
}
offset += frame.size;
}
// Keep remaining incomplete frame data
buffer = buffer.slice(offset);
}
},
onEnd: (reason?: string) => {
if (buffer.length > 0) {
const text = buffer.toString('utf-8');
if (text.trim()) {
safeEnqueue(`event: log\ndata: ${JSON.stringify({ text, containerName })}\n\n`);
}
}
safeEnqueue(`event: end\ndata: ${JSON.stringify({ reason: reason || 'stream ended' })}\n\n`);
if (!controllerClosed) {
try {
controller.close();
} catch {
// Already closed
}
}
},
onError: (error: string) => {
safeEnqueue(`event: error\ndata: ${JSON.stringify({ error })}\n\n`);
if (!controllerClosed) {
try {
controller.close();
} catch {
// Already closed
}
}
}
}
);
cancelStream = cancel;
},
cancel() {
controllerClosed = true;
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
}
if (cancelStream) {
cancelStream();
cancelStream = null;
}
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no'
}
});
}
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
const containerId = params.id;
const tail = url.searchParams.get('tail') || '100';
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'logs', envIdNum)) {
return new Response(JSON.stringify({ error: 'Permission denied' }), {
status: 403,
headers: { 'Content-Type': 'application/json' }
});
}
const config = await getDockerConfig(envIdNum);
// Handle Hawser Edge mode separately
if (config.type === 'hawser-edge') {
return handleEdgeLogsStream(containerId, tail, config.environmentId!);
}
// First, check if container has TTY enabled and get container name
let hasTty = false;
let containerName = containerId.substring(0, 12); // Default to short ID
try {
const inspectPath = `/containers/${containerId}/json`;
let inspectResponse: Response;
if (config.type === 'socket') {
inspectResponse = await fetch(`http://localhost${inspectPath}`, {
// @ts-ignore - Bun supports unix socket
unix: config.socketPath
});
} else {
const inspectUrl = `${config.type}://${config.host}:${config.port}${inspectPath}`;
const inspectHeaders: Record<string, string> = {};
if (config.hawserToken) inspectHeaders['X-Hawser-Token'] = config.hawserToken;
inspectResponse = await fetch(inspectUrl, {
headers: inspectHeaders,
// @ts-ignore
tls: config.type === 'https' ? { ca: config.ca, cert: config.cert, key: config.key } : undefined
});
}
if (inspectResponse.ok) {
const info = await inspectResponse.json();
hasTty = info.Config?.Tty ?? false;
// Get container name (strip leading /)
containerName = info.Name?.replace(/^\//, '') || containerName;
}
} catch {
// Ignore - default to demux mode
}
// Build the logs URL with follow=true for streaming
const logsPath = `/containers/${containerId}/logs?stdout=true&stderr=true&follow=true&tail=${tail}&timestamps=true`;
let controllerClosed = false;
let abortController: AbortController | null = new AbortController();
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
const safeEnqueue = (data: string) => {
if (!controllerClosed) {
try {
controller.enqueue(encoder.encode(data));
} catch {
controllerClosed = true;
}
}
};
// Send heartbeat to keep connection alive (every 5s for Traefik)
heartbeatInterval = setInterval(() => {
safeEnqueue(`: keepalive\n\n`);
}, 5000);
try {
let response: Response;
if (config.type === 'socket') {
response = await fetch(`http://localhost${logsPath}`, {
// @ts-ignore - Bun supports unix socket
unix: config.socketPath,
signal: abortController?.signal
});
} else {
const logsUrl = `${config.type}://${config.host}:${config.port}${logsPath}`;
const logsHeaders: Record<string, string> = {};
if (config.hawserToken) logsHeaders['X-Hawser-Token'] = config.hawserToken;
response = await fetch(logsUrl, {
headers: logsHeaders,
signal: abortController?.signal,
// @ts-ignore
tls: config.type === 'https' ? { ca: config.ca, cert: config.cert, key: config.key } : undefined
});
}
if (!response.ok) {
safeEnqueue(`event: error\ndata: ${JSON.stringify({ error: `Docker API error: ${response.status}` })}\n\n`);
if (!controllerClosed) controller.close();
return;
}
// Send connected event
safeEnqueue(`event: connected\ndata: ${JSON.stringify({ containerId, containerName, hasTty })}\n\n`);
const reader = response.body?.getReader();
if (!reader) {
safeEnqueue(`event: error\ndata: ${JSON.stringify({ error: 'No response body' })}\n\n`);
if (!controllerClosed) controller.close();
return;
}
let buffer = Buffer.alloc(0);
while (!controllerClosed) {
const { done, value } = await reader.read();
if (done) {
// Send any remaining buffer content
if (buffer.length > 0) {
const text = buffer.toString('utf-8');
if (text.trim()) {
safeEnqueue(`event: log\ndata: ${JSON.stringify({ text, containerName })}\n\n`);
}
}
safeEnqueue(`event: end\ndata: ${JSON.stringify({ reason: 'stream ended' })}\n\n`);
break;
}
if (value) {
if (hasTty) {
// TTY mode: raw text, no demux needed
const text = new TextDecoder().decode(value);
if (text) {
safeEnqueue(`event: log\ndata: ${JSON.stringify({ text, containerName })}\n\n`);
}
} else {
// Non-TTY mode: demux Docker stream frames
buffer = Buffer.concat([buffer, Buffer.from(value)]);
// Process complete frames
let offset = 0;
while (true) {
const frame = parseDockerFrame(buffer, offset);
if (!frame) break;
// Stream type 1 = stdout, 2 = stderr
if (frame.payload) {
safeEnqueue(`event: log\ndata: ${JSON.stringify({ text: frame.payload, containerName, stream: frame.type === 2 ? 'stderr' : 'stdout' })}\n\n`);
}
offset += frame.size;
}
// Keep remaining incomplete frame data
buffer = buffer.slice(offset);
}
}
}
reader.releaseLock();
} catch (error) {
if (!controllerClosed) {
const errorMsg = error instanceof Error ? error.message : String(error);
if (!errorMsg.includes('abort')) {
safeEnqueue(`event: error\ndata: ${JSON.stringify({ error: errorMsg })}\n\n`);
}
}
}
if (!controllerClosed) {
try {
controller.close();
} catch {
// Already closed
}
}
},
cancel() {
controllerClosed = true;
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
}
abortController?.abort();
abortController = null;
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no'
}
});
};
@@ -0,0 +1,32 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { pauseContainer, inspectContainer } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
export const POST: RequestHandler = async (event) => {
const { params, url, cookies } = event;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context (pause/unpause uses 'stop' permission)
if (auth.authEnabled && !await auth.can('containers', 'stop', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const details = await inspectContainer(params.id, envIdNum);
const containerName = details.Name.replace(/^\//, '');
await pauseContainer(params.id, envIdNum);
// Audit log
await auditContainer(event, 'pause', params.id, containerName, envIdNum);
return json({ success: true });
} catch (error) {
console.error('Failed to pause container:', error);
return json({ error: 'Failed to pause container' }, { status: 500 });
}
};
@@ -0,0 +1,53 @@
import { json } from '@sveltejs/kit';
import { renameContainer, inspectContainer } from '$lib/server/docker';
import { renameAutoUpdateSchedule } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async (event) => {
const { params, request, url, cookies } = event;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context (renaming requires create permission)
if (auth.authEnabled && !await auth.can('containers', 'create', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const { name } = await request.json();
if (!name || typeof name !== 'string') {
return json({ error: 'New name is required' }, { status: 400 });
}
// Get old container name before renaming
let oldName = params.id;
try {
const details = await inspectContainer(params.id, envIdNum);
oldName = details.Name?.replace(/^\//, '') || params.id;
} catch {
// Container might not exist or other error, use ID
}
await renameContainer(params.id, name, envIdNum);
// Audit log
await auditContainer(event, 'rename', params.id, name, envIdNum, { previousId: params.id, newName: name });
// Update schedule if exists
try {
await renameAutoUpdateSchedule(oldName, name, envIdNum);
} catch (error) {
console.error('Failed to update schedule name:', error);
// Don't fail the rename if schedule update fails
}
return json({ success: true });
} catch (error) {
console.error('Error renaming container:', error);
return json({ error: 'Failed to rename container' }, { status: 500 });
}
};
@@ -0,0 +1,45 @@
import { json } from '@sveltejs/kit';
import { restartContainer, inspectContainer } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async (event) => {
const { params, url, cookies } = event;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'restart', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
// Environment access check (enterprise only)
if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) {
return json({ error: 'Access denied to this environment' }, { status: 403 });
}
try {
// Get container name for audit
let containerName = params.id;
try {
const details = await inspectContainer(params.id, envIdNum);
containerName = details.Name?.replace(/^\//, '') || params.id;
} catch {
// Use ID if can't get name
}
await restartContainer(params.id, envIdNum);
// Audit log
await auditContainer(event, 'restart', params.id, containerName, envIdNum);
return json({ success: true });
} catch (error) {
console.error('Error restarting container:', error);
return json({ error: 'Failed to restart container' }, { status: 500 });
}
};
@@ -0,0 +1,38 @@
import { json } from '@sveltejs/kit';
import { startContainer, inspectContainer } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async (event) => {
const { params, url, cookies } = event;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'start', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
// Environment access check (enterprise only)
if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) {
return json({ error: 'Access denied to this environment' }, { status: 403 });
}
try {
await startContainer(params.id, envIdNum);
const details = await inspectContainer(params.id, envIdNum);
const containerName = details.Name.replace(/^\//, '');
// Audit log
await auditContainer(event, 'start', params.id, containerName, envIdNum);
return json({ success: true });
} catch (error) {
console.error('Error starting container:', error);
return json({ error: 'Failed to start container' }, { status: 500 });
}
};
@@ -0,0 +1,91 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getContainerStats } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { hasEnvironments } from '$lib/server/db';
function calculateCpuPercent(stats: any): number {
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage;
const systemDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage;
const cpuCount = stats.cpu_stats.online_cpus || stats.cpu_stats.cpu_usage.percpu_usage?.length || 1;
if (systemDelta > 0 && cpuDelta > 0) {
return (cpuDelta / systemDelta) * cpuCount * 100;
}
return 0;
}
function calculateNetworkIO(stats: any): { rx: number; tx: number } {
let rx = 0;
let tx = 0;
if (stats.networks) {
for (const iface of Object.values(stats.networks) as any[]) {
rx += iface.rx_bytes || 0;
tx += iface.tx_bytes || 0;
}
}
return { rx, tx };
}
function calculateBlockIO(stats: any): { read: number; write: number } {
let read = 0;
let write = 0;
const ioStats = stats.blkio_stats?.io_service_bytes_recursive;
if (Array.isArray(ioStats)) {
for (const entry of ioStats) {
if (entry.op === 'read' || entry.op === 'Read') {
read += entry.value || 0;
} else if (entry.op === 'write' || entry.op === 'Write') {
write += entry.value || 0;
}
}
}
return { read, write };
}
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context (stats uses view permission)
if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
// Early return if no environments configured (fresh install)
if (!await hasEnvironments()) {
return json({ error: 'No environment configured' }, { status: 404 });
}
try {
const stats = await getContainerStats(params.id, envIdNum) as any;
const cpuPercent = calculateCpuPercent(stats);
const memoryUsage = stats.memory_stats?.usage || 0;
const memoryLimit = stats.memory_stats?.limit || 1;
const memoryPercent = (memoryUsage / memoryLimit) * 100;
const networkIO = calculateNetworkIO(stats);
const blockIO = calculateBlockIO(stats);
return json({
cpuPercent: Math.round(cpuPercent * 100) / 100,
memoryUsage,
memoryLimit,
memoryPercent: Math.round(memoryPercent * 100) / 100,
networkRx: networkIO.rx,
networkTx: networkIO.tx,
blockRead: blockIO.read,
blockWrite: blockIO.write,
timestamp: Date.now()
});
} catch (error: any) {
console.error('Failed to get container stats:', error);
return json({ error: error.message || 'Failed to get stats' }, { status: 500 });
}
};
@@ -0,0 +1,38 @@
import { json } from '@sveltejs/kit';
import { stopContainer, inspectContainer } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async (event) => {
const { params, url, cookies } = event;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'stop', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
// Environment access check (enterprise only)
if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) {
return json({ error: 'Access denied to this environment' }, { status: 403 });
}
try {
const details = await inspectContainer(params.id, envIdNum);
const containerName = details.Name.replace(/^\//, '');
await stopContainer(params.id, envIdNum);
// Audit log
await auditContainer(event, 'stop', params.id, containerName, envIdNum);
return json({ success: true });
} catch (error) {
console.error('Error stopping container:', error);
return json({ error: 'Failed to stop container' }, { status: 500 });
}
};
@@ -0,0 +1,73 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { execInContainer, getContainerTop } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
function parsePsOutput(output: string): { Titles: string[]; Processes: string[][] } | null {
const lines = output.trim().split('\n').filter(line => line.trim());
if (lines.length === 0) return null;
const headerLine = lines[0];
const headers = headerLine.trim().split(/\s+/);
// Find the index of COMMAND (last column, can have spaces)
const commandIndex = headers.findIndex(h => h === 'COMMAND' || h === 'CMD');
const processes = lines.slice(1).map(line => {
const parts = line.trim().split(/\s+/);
// COMMAND can have spaces, so join everything from commandIndex onwards
if (commandIndex !== -1 && parts.length > commandIndex) {
const beforeCommand = parts.slice(0, commandIndex);
const command = parts.slice(commandIndex).join(' ');
return [...beforeCommand, command];
}
return parts;
});
return { Titles: headers, Processes: processes };
}
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context (process list uses inspect permission)
if (auth.authEnabled && !await auth.can('containers', 'inspect', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
// Try different ps commands in order of preference
const psCommands = [
['ps', 'aux', '--sort=-pcpu'], // GNU ps with CPU sort
['ps', 'aux'], // GNU ps without sort
['ps', '-ef'], // POSIX ps
];
for (const cmd of psCommands) {
try {
const output = await execInContainer(params.id, cmd, envIdNum);
// Check if output looks like an error message (BusyBox error, etc.)
if (output.includes('unrecognized option') || output.includes('Usage:') || output.includes('BusyBox')) {
continue;
}
const result = parsePsOutput(output);
if (result && result.Processes.length > 0) {
return json({ ...result, source: 'ps' });
}
} catch {
// Try next command
}
}
// Fallback to docker top API
const top = await getContainerTop(params.id, envIdNum);
return json({ ...top, source: 'top' });
} catch (error: any) {
console.error('Failed to get container processes:', error);
return json({ error: error.message || 'Failed to get processes' }, { status: 500 });
}
};
@@ -0,0 +1,32 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { unpauseContainer, inspectContainer } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
export const POST: RequestHandler = async (event) => {
const { params, url, cookies } = event;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context (unpause uses 'start' permission)
if (auth.authEnabled && !await auth.can('containers', 'start', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const details = await inspectContainer(params.id, envIdNum);
const containerName = details.Name.replace(/^\//, '');
await unpauseContainer(params.id, envIdNum);
// Audit log
await auditContainer(event, 'unpause', params.id, containerName, envIdNum);
return json({ success: true });
} catch (error) {
console.error('Failed to unpause container:', error);
return json({ error: 'Failed to unpause container' }, { status: 500 });
}
};
@@ -0,0 +1,43 @@
import { json } from '@sveltejs/kit';
import { updateContainer, type CreateContainerOptions } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import { removePendingContainerUpdate } from '$lib/server/db';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async (event) => {
const { params, request, url, cookies } = event;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context (update requires create permission)
if (auth.authEnabled && !await auth.can('containers', 'create', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const body = await request.json();
const { startAfterUpdate, ...options } = body;
console.log(`Updating container ${params.id} with name: ${options.name}`);
const container = await updateContainer(params.id, options, startAfterUpdate, envIdNum);
// Clear pending update indicator (if any) since container was just updated
if (envIdNum) {
await removePendingContainerUpdate(envIdNum, params.id).catch(() => {
// Ignore errors - record may not exist
});
}
// Audit log - include full options to see what was modified
await auditContainer(event, 'update', container.id, options.name, envIdNum, { ...options, startAfterUpdate });
return json({ success: true, id: container.id });
} catch (error) {
console.error('Error updating container:', error);
return json({ error: 'Failed to update container', details: String(error) }, { status: 500 });
}
};
@@ -0,0 +1,548 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import {
listContainers,
inspectContainer,
stopContainer,
removeContainer,
createContainer,
pullImage,
getTempImageTag,
isDigestBasedImage,
getImageIdByTag,
removeTempImage,
tagImage
} from '$lib/server/docker';
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';
export interface ScanResult {
critical: number;
high: number;
medium: number;
low: number;
negligible?: number;
unknown?: number;
}
export interface ScannerResult extends ScanResult {
scanner: 'grype' | 'trivy';
}
export interface UpdateProgress {
type: 'start' | 'progress' | 'pull_log' | 'scan_start' | 'scan_log' | 'scan_complete' | 'blocked' | 'complete' | 'error';
containerId?: string;
containerName?: string;
step?: 'pulling' | 'scanning' | 'stopping' | 'removing' | 'creating' | 'starting' | 'done' | 'failed' | 'blocked' | 'skipped';
message?: string;
current?: number;
total?: number;
success?: boolean;
error?: string;
summary?: {
total: number;
success: number;
failed: number;
blocked: number;
skipped: number;
};
// Pull log specific fields
pullStatus?: string;
pullId?: string;
pullProgress?: string;
// Scan specific fields
scanResult?: ScanResult;
scannerResults?: ScannerResult[];
blockReason?: string;
scanner?: string;
}
/**
* Batch update containers with streaming progress.
* Expects JSON body: { containerIds: string[], vulnerabilityCriteria?: VulnerabilityCriteria }
*/
export const POST: RequestHandler = async (event) => {
const { url, cookies, request } = event;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Need create permission to recreate containers
if (auth.authEnabled && !await auth.can('containers', 'create', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
let body: { containerIds: string[]; vulnerabilityCriteria?: VulnerabilityCriteria };
try {
body = await request.json();
} catch {
return json({ error: 'Invalid JSON body' }, { status: 400 });
}
const { containerIds, vulnerabilityCriteria = 'never' } = body;
if (!containerIds || !Array.isArray(containerIds) || containerIds.length === 0) {
return json({ error: 'containerIds array is required' }, { status: 400 });
}
const encoder = new TextEncoder();
let controllerClosed = false;
let keepaliveInterval: ReturnType<typeof setInterval> | null = null;
const stream = new ReadableStream({
async start(controller) {
const safeEnqueue = (data: UpdateProgress) => {
if (!controllerClosed) {
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
} catch {
controllerClosed = true;
}
}
};
// Send SSE keepalive comments every 5s to prevent Traefik (10s idle timeout) from closing connection
keepaliveInterval = setInterval(() => {
if (controllerClosed) return;
try {
controller.enqueue(encoder.encode(`: keepalive\n\n`));
} catch {
controllerClosed = true;
}
}, 5000);
let successCount = 0;
let failCount = 0;
let blockedCount = 0;
let skippedCount = 0;
// Get scanner settings for this environment
const scannerSettings = await getScannerSettings(envIdNum);
// Scan if scanning is enabled (scanner !== 'none')
// The vulnerabilityCriteria only controls whether to BLOCK updates, not whether to SCAN
const shouldScan = scannerSettings.scanner !== 'none';
// Send start event
safeEnqueue({
type: 'start',
total: containerIds.length,
message: `Starting update of ${containerIds.length} container${containerIds.length > 1 ? 's' : ''}${shouldScan ? ' with vulnerability scanning' : ''}`
});
// Process containers sequentially
for (let i = 0; i < containerIds.length; i++) {
const containerId = containerIds[i];
let containerName = 'unknown';
try {
// Find container
const containers = await listContainers(true, envIdNum);
const container = containers.find(c => c.id === containerId);
if (!container) {
safeEnqueue({
type: 'progress',
containerId,
containerName: 'unknown',
step: 'failed',
current: i + 1,
total: containerIds.length,
success: false,
error: 'Container not found'
});
failCount++;
continue;
}
containerName = container.name;
// 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 currentImageId = inspectData.Image;
// Skip Dockhand container - cannot update itself
if (isDockhandContainer(imageName)) {
safeEnqueue({
type: 'progress',
containerId,
containerName,
step: 'skipped',
current: i + 1,
total: containerIds.length,
success: true,
message: `Skipping ${containerName} - cannot update Dockhand itself`
});
skippedCount++;
continue;
}
// Step 1: Pull latest image
safeEnqueue({
type: 'progress',
containerId,
containerName,
step: 'pulling',
current: i + 1,
total: containerIds.length,
message: `Pulling ${imageName}...`
});
try {
await pullImage(imageName, (data: any) => {
// Send pull progress as log entries
if (data.status) {
safeEnqueue({
type: 'pull_log',
containerId,
containerName,
pullStatus: data.status,
pullId: data.id,
pullProgress: data.progress
});
}
}, envIdNum);
} catch (pullError: any) {
safeEnqueue({
type: 'progress',
containerId,
containerName,
step: 'failed',
current: i + 1,
total: containerIds.length,
success: false,
error: `Pull failed: ${pullError.message}`
});
failCount++;
continue;
}
// SAFE-PULL FLOW with vulnerability scanning
if (shouldScan && !isDigestBasedImage(imageName)) {
const tempTag = getTempImageTag(imageName);
// Get new image ID
const newImageId = await getImageIdByTag(imageName, envIdNum);
if (!newImageId) {
safeEnqueue({
type: 'progress',
containerId,
containerName,
step: 'failed',
current: i + 1,
total: containerIds.length,
success: false,
error: 'Failed to get new image ID after pull'
});
failCount++;
continue;
}
// Restore original tag to old image (safety)
const [oldRepo, oldTag] = parseImageNameAndTag(imageName);
try {
await tagImage(currentImageId, oldRepo, oldTag, envIdNum);
} catch {
// Ignore - old image might have been removed
}
// Tag new image with temp suffix
const [tempRepo, tempTagName] = parseImageNameAndTag(tempTag);
await tagImage(newImageId, tempRepo, tempTagName, envIdNum);
// Step 2: Scan temp image
safeEnqueue({
type: 'scan_start',
containerId,
containerName,
step: 'scanning',
current: i + 1,
total: containerIds.length,
message: `Scanning ${imageName} for vulnerabilities...`
});
let scanBlocked = false;
let blockReason = '';
let finalScanResult: ScanResult | undefined;
let individualScannerResults: ScannerResult[] = [];
try {
const scanResults = await scanImage(tempTag, envIdNum, (progress) => {
if (progress.message) {
safeEnqueue({
type: 'scan_log',
containerId,
containerName,
scanner: progress.scanner,
message: progress.message
});
}
});
if (scanResults.length > 0) {
const scanSummary = combineScanSummaries(scanResults);
finalScanResult = {
critical: scanSummary.critical,
high: scanSummary.high,
medium: scanSummary.medium,
low: scanSummary.low,
negligible: scanSummary.negligible,
unknown: scanSummary.unknown
};
// Build individual scanner results
individualScannerResults = scanResults.map(result => ({
scanner: result.scanner as 'grype' | 'trivy',
critical: result.summary.critical,
high: result.summary.high,
medium: result.summary.medium,
low: result.summary.low,
negligible: result.summary.negligible,
unknown: result.summary.unknown
}));
// Save scan results
for (const result of scanResults) {
try {
await saveVulnerabilityScan({
environmentId: envIdNum,
imageId: newImageId,
imageName: result.imageName,
scanner: result.scanner,
scannedAt: result.scannedAt,
scanDuration: result.scanDuration,
criticalCount: result.summary.critical,
highCount: result.summary.high,
mediumCount: result.summary.medium,
lowCount: result.summary.low,
negligibleCount: result.summary.negligible,
unknownCount: result.summary.unknown,
vulnerabilities: result.vulnerabilities,
error: result.error ?? null
});
} catch { /* ignore save errors */ }
}
// Check if blocked
const { blocked, reason } = shouldBlockUpdate(vulnerabilityCriteria, scanSummary, undefined);
if (blocked) {
scanBlocked = true;
blockReason = reason;
}
}
safeEnqueue({
type: 'scan_complete',
containerId,
containerName,
scanResult: finalScanResult,
scannerResults: individualScannerResults.length > 0 ? individualScannerResults : undefined,
message: finalScanResult
? `Scan complete: ${finalScanResult.critical} critical, ${finalScanResult.high} high, ${finalScanResult.medium} medium, ${finalScanResult.low} low`
: 'Scan complete: no vulnerabilities found'
});
} catch (scanErr: any) {
safeEnqueue({
type: 'progress',
containerId,
containerName,
step: 'failed',
current: i + 1,
total: containerIds.length,
success: false,
error: `Scan failed: ${scanErr.message}`
});
// Clean up temp image on scan failure
try {
await removeTempImage(newImageId, envIdNum);
} catch { /* ignore cleanup errors */ }
failCount++;
continue;
}
if (scanBlocked) {
// BLOCKED - Remove temp image and skip this container
safeEnqueue({
type: 'blocked',
containerId,
containerName,
step: 'blocked',
current: i + 1,
total: containerIds.length,
success: false,
scanResult: finalScanResult,
scannerResults: individualScannerResults.length > 0 ? individualScannerResults : undefined,
blockReason,
message: `Update blocked: ${blockReason}`
});
try {
await removeTempImage(newImageId, envIdNum);
} catch { /* ignore cleanup errors */ }
blockedCount++;
continue;
}
// APPROVED - Re-tag to original
await tagImage(newImageId, oldRepo, oldTag, envIdNum);
try {
await removeTempImage(tempTag, envIdNum);
} catch { /* ignore cleanup errors */ }
}
// Step 3: Stop container if running
if (wasRunning) {
safeEnqueue({
type: 'progress',
containerId,
containerName,
step: 'stopping',
current: i + 1,
total: containerIds.length,
message: `Stopping ${containerName}...`
});
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);
// 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 || '' };
}
}
}
// 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) {
safeEnqueue({
type: 'progress',
containerId,
containerName,
step: 'starting',
current: i + 1,
total: containerIds.length,
message: `Starting ${containerName}...`
});
await newContainer.start();
}
// Audit log
await auditContainer(event, 'update', newContainer.id, containerName, envIdNum, { batchUpdate: true });
// Done with this container - use original containerId for UI consistency
safeEnqueue({
type: 'progress',
containerId,
containerName,
step: 'done',
current: i + 1,
total: containerIds.length,
success: true,
message: `${containerName} updated successfully`
});
successCount++;
// Clear pending update indicator from database
if (envIdNum) {
await removePendingContainerUpdate(envIdNum, containerId).catch(() => {
// Ignore errors - record may not exist
});
}
} catch (error: any) {
safeEnqueue({
type: 'progress',
containerId,
containerName,
step: 'failed',
current: i + 1,
total: containerIds.length,
success: false,
error: error.message
});
failCount++;
}
}
// Send complete event
safeEnqueue({
type: 'complete',
summary: {
total: containerIds.length,
success: successCount,
failed: failCount,
blocked: blockedCount,
skipped: skippedCount
},
message: skippedCount > 0 || blockedCount > 0
? `Updated ${successCount} of ${containerIds.length} containers${blockedCount > 0 ? ` (${blockedCount} blocked)` : ''}${skippedCount > 0 ? ` (${skippedCount} skipped)` : ''}`
: `Updated ${successCount} of ${containerIds.length} containers`
});
clearInterval(keepaliveInterval);
controller.close();
},
cancel() {
controllerClosed = true;
if (keepaliveInterval) {
clearInterval(keepaliveInterval);
}
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
}
});
};
@@ -0,0 +1,154 @@
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 { auditContainer } from '$lib/server/audit';
export interface BatchUpdateResult {
containerId: string;
containerName: string;
success: boolean;
error?: string;
}
/**
* Batch update containers by recreating them with latest images.
* Expects JSON body: { containerIds: string[] }
*/
export const POST: RequestHandler = async (event) => {
const { url, cookies, request } = event;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Need create permission to recreate containers
if (auth.authEnabled && !await auth.can('containers', 'create', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const body = await request.json();
const { containerIds } = body as { containerIds: string[] };
if (!containerIds || !Array.isArray(containerIds) || containerIds.length === 0) {
return json({ error: 'containerIds array is required' }, { status: 400 });
}
const results: BatchUpdateResult[] = [];
// Process containers sequentially to avoid resource conflicts
for (const containerId of containerIds) {
try {
const containers = await listContainers(true, envIdNum);
const container = containers.find(c => c.id === containerId);
if (!container) {
results.push({
containerId,
containerName: 'unknown',
success: false,
error: 'Container not found'
});
continue;
}
// 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;
// Pull latest image first
try {
await pullImage(imageName, undefined, envIdNum);
} catch (pullError: any) {
results.push({
containerId,
containerName,
success: false,
error: `Pull failed: ${pullError.message}`
});
continue;
}
// Stop container if running
if (wasRunning) {
await stopContainer(containerId, envIdNum);
}
// Remove old container
await removeContainer(containerId, true, envIdNum);
// 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 || '' };
}
}
}
// 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();
}
// Audit log
await auditContainer(event, 'update', newContainer.id, containerName, envIdNum, { batchUpdate: true });
results.push({
containerId: newContainer.id,
containerName,
success: true
});
} catch (error: any) {
results.push({
containerId,
containerName: 'unknown',
success: false,
error: error.message
});
}
}
const successCount = results.filter(r => r.success).length;
const failCount = results.filter(r => !r.success).length;
return json({
success: failCount === 0,
results,
summary: {
total: results.length,
success: successCount,
failed: failCount
}
});
} catch (error: any) {
console.error('Error in batch update:', error);
return json({ error: 'Failed to batch update containers', details: error.message }, { status: 500 });
}
};
@@ -0,0 +1,111 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { listContainers, inspectContainer, checkImageUpdateAvailable } from '$lib/server/docker';
import { clearPendingContainerUpdates, addPendingContainerUpdate } from '$lib/server/db';
export interface UpdateCheckResult {
containerId: string;
containerName: string;
imageName: string;
hasUpdate: boolean;
currentDigest?: string;
newDigest?: string;
error?: string;
isLocalImage?: boolean;
}
/**
* Check all containers for available image updates.
* Returns all results at once after checking in parallel.
*/
export const POST: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Need at least view permission
if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
// Clear existing pending updates for this environment before checking
if (envIdNum) {
await clearPendingContainerUpdates(envIdNum);
}
const containers = await listContainers(true, envIdNum);
// Check container for updates
const checkContainer = async (container: typeof containers[0]): Promise<UpdateCheckResult> => {
try {
// Get container's image name from config
const inspectData = await inspectContainer(container.id, envIdNum) as any;
const imageName = inspectData.Config?.Image;
const currentImageId = inspectData.Image;
if (!imageName) {
return {
containerId: container.id,
containerName: container.name,
imageName: container.image,
hasUpdate: false,
error: 'Could not determine image name'
};
}
// Use shared update detection function
const result = await checkImageUpdateAvailable(imageName, currentImageId, envIdNum);
return {
containerId: container.id,
containerName: container.name,
imageName,
hasUpdate: result.hasUpdate,
currentDigest: result.currentDigest,
newDigest: result.registryDigest,
error: result.error,
isLocalImage: result.isLocalImage
};
} catch (error: any) {
return {
containerId: container.id,
containerName: container.name,
imageName: container.image,
hasUpdate: false,
error: error.message
};
}
};
// Check all containers in parallel
const results = await Promise.all(containers.map(checkContainer));
const updatesFound = results.filter(r => r.hasUpdate).length;
// Save containers with updates to the database for persistence
if (envIdNum) {
for (const result of results) {
if (result.hasUpdate) {
await addPendingContainerUpdate(
envIdNum,
result.containerId,
result.containerName,
result.imageName
);
}
}
}
return json({
total: containers.length,
updatesFound,
results
});
} catch (error: any) {
console.error('Error checking for updates:', error);
return json({ error: 'Failed to check for updates', details: error.message }, { status: 500 });
}
};
@@ -0,0 +1,67 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { getPendingContainerUpdates, removePendingContainerUpdate } from '$lib/server/db';
/**
* Get pending container updates for an environment.
*/
export const GET: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
if (!envIdNum) {
return json({ error: 'Environment ID required' }, { status: 400 });
}
// Need at least view permission
if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const pendingUpdates = await getPendingContainerUpdates(envIdNum);
return json({
environmentId: envIdNum,
pendingUpdates: pendingUpdates.map(u => ({
containerId: u.containerId,
containerName: u.containerName,
currentImage: u.currentImage,
checkedAt: u.checkedAt
}))
});
} catch (error: any) {
console.error('Error getting pending updates:', error);
return json({ error: 'Failed to get pending updates', details: error.message }, { status: 500 });
}
};
/**
* Remove a pending container update (e.g., after manual update).
*/
export const DELETE: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const containerId = url.searchParams.get('containerId');
const envIdNum = envId ? parseInt(envId) : undefined;
if (!envIdNum || !containerId) {
return json({ error: 'Environment ID and container ID required' }, { status: 400 });
}
// Need manage permission to delete
if (auth.authEnabled && !await auth.can('containers', 'manage', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
await removePendingContainerUpdate(envIdNum, containerId);
return json({ success: true });
} catch (error: any) {
console.error('Error removing pending update:', error);
return json({ error: 'Failed to remove pending update', details: error.message }, { status: 500 });
}
};
@@ -0,0 +1,24 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { listContainersWithSize } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
export const GET: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const sizes = await listContainersWithSize(true, envIdNum);
return json(sizes);
} catch (error) {
console.error('Failed to get container sizes:', error);
return json({}, { status: 500 });
}
};
+148
View File
@@ -0,0 +1,148 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { listContainers, getContainerStats } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { hasEnvironments } from '$lib/server/db';
import type { ContainerStats } from '$lib/types';
function calculateCpuPercent(stats: any): number {
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage;
const systemDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage;
const cpuCount = stats.cpu_stats.online_cpus || stats.cpu_stats.cpu_usage.percpu_usage?.length || 1;
if (systemDelta > 0 && cpuDelta > 0) {
return (cpuDelta / systemDelta) * cpuCount * 100;
}
return 0;
}
function calculateNetworkIO(stats: any): { rx: number; tx: number } {
let rx = 0;
let tx = 0;
if (stats.networks) {
for (const iface of Object.values(stats.networks) as any[]) {
rx += iface.rx_bytes || 0;
tx += iface.tx_bytes || 0;
}
}
return { rx, tx };
}
function calculateBlockIO(stats: any): { read: number; write: number } {
let read = 0;
let write = 0;
const ioStats = stats.blkio_stats?.io_service_bytes_recursive;
if (Array.isArray(ioStats)) {
for (const entry of ioStats) {
if (entry.op === 'read' || entry.op === 'Read') {
read += entry.value || 0;
} else if (entry.op === 'write' || entry.op === 'Write') {
write += entry.value || 0;
}
}
}
return { read, write };
}
// Helper to add timeout to promises
function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T> {
return Promise.race([
promise,
new Promise<T>((resolve) => setTimeout(() => resolve(fallback), ms))
]);
}
export const GET: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
const debugContainer = url.searchParams.get('debug'); // Get raw stats for specific container
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
// Early return if no environments configured (fresh install)
if (!await hasEnvironments()) {
return json([]);
}
// Early return if no environment specified
if (!envIdNum) {
return json([]);
}
try {
// Get all running containers with timeout
const containers = await withTimeout(
listContainers(true, envIdNum),
5000, // 5 second timeout
[]
);
const runningContainers = containers.filter(c => c.state === 'running');
// Debug mode: return raw stats for specific container
if (debugContainer) {
const container = runningContainers.find(c => c.name === debugContainer);
if (container) {
const rawStats = await getContainerStats(container.id, envIdNum);
return json({
name: container.name,
memory_stats: (rawStats as any).memory_stats
});
}
return json({ error: 'Container not found' }, { status: 404 });
}
// Get stats for each running container (in parallel with timeout)
const statsPromises = runningContainers.map(async (container) => {
try {
const stats = await withTimeout(
getContainerStats(container.id, envIdNum) as Promise<any>,
3000, // 3 second timeout per container
null
);
if (!stats) return null;
const cpuPercent = calculateCpuPercent(stats);
// Use raw memory usage (total memory attributed to container)
const memoryUsage = stats.memory_stats?.usage || 0;
const memoryLimit = stats.memory_stats?.limit || 1;
const memoryPercent = (memoryUsage / memoryLimit) * 100;
const networkIO = calculateNetworkIO(stats);
const blockIO = calculateBlockIO(stats);
return {
id: container.id,
name: container.name,
cpuPercent: Math.round(cpuPercent * 100) / 100,
memoryUsage,
memoryLimit,
memoryPercent: Math.round(memoryPercent * 100) / 100,
networkRx: networkIO.rx,
networkTx: networkIO.tx,
blockRead: blockIO.read,
blockWrite: blockIO.write
};
} catch (err) {
// Silently skip failed containers
return null;
}
});
const allStats = await Promise.all(statsPromises);
const validStats = allStats.filter((s): s is ContainerStats => s !== null);
return json(validStats);
} catch (error: any) {
console.error('Failed to get container stats:', error);
return json([], { status: 200 }); // Return empty array instead of error
}
};
@@ -0,0 +1,53 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { getDashboardPreferences, saveDashboardPreferences } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
try {
// Get user-specific preferences, or fall back to global preferences
const userId = auth.user?.id ?? null;
const prefs = await getDashboardPreferences(userId);
// If no preferences exist, return empty gridLayout
if (!prefs) {
return json({
id: 0,
userId: null,
gridLayout: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
});
}
return json(prefs);
} catch (error) {
console.error('Failed to get dashboard preferences:', error);
return json({ error: 'Failed to get dashboard preferences' }, { status: 500 });
}
};
export const POST: RequestHandler = async ({ request, cookies }) => {
const auth = await authorize(cookies);
try {
const body = await request.json();
const { gridLayout } = body;
if (!gridLayout || !Array.isArray(gridLayout)) {
return json({ error: 'gridLayout is required and must be an array' }, { status: 400 });
}
const userId = auth.user?.id ?? null;
const prefs = await saveDashboardPreferences({
userId,
gridLayout
});
return json(prefs);
} catch (error) {
console.error('Failed to save dashboard preferences:', error);
return json({ error: 'Failed to save dashboard preferences' }, { status: 500 });
}
};
+307
View File
@@ -0,0 +1,307 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import {
getEnvironments,
getLatestHostMetrics,
getContainerEventStats,
getEnvSetting,
hasEnvironments,
getEnvUpdateCheckSettings
} from '$lib/server/db';
import {
listContainers,
listImages,
listVolumes,
listNetworks,
getDockerInfo,
getDiskUsage
} from '$lib/server/docker';
import { listComposeStacks } from '$lib/server/stacks';
import { authorize } from '$lib/server/authorize';
import { parseLabels } from '$lib/utils/label-colors';
// Helper to add timeout to promises
function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T> {
return Promise.race([
promise,
new Promise<T>((resolve) => setTimeout(() => resolve(fallback), ms))
]);
}
// Loading states for progressive tile updates
export interface LoadingStates {
containers?: boolean;
images?: boolean;
volumes?: boolean;
networks?: boolean;
stacks?: boolean;
diskUsage?: boolean;
topContainers?: boolean;
}
export interface EnvironmentStats {
id: number;
name: string;
host?: string;
port?: number | null;
icon: string;
socketPath?: string;
collectActivity: boolean;
collectMetrics: boolean;
scannerEnabled: boolean;
updateCheckEnabled: boolean;
updateCheckAutoUpdate: boolean;
labels?: string[];
connectionType: 'socket' | 'direct' | 'hawser-standard' | 'hawser-edge';
online: boolean;
error?: string;
containers: {
total: number;
running: number;
stopped: number;
paused: number;
restarting: number;
unhealthy: number;
};
images: {
total: number;
totalSize: number;
};
volumes: {
total: number;
totalSize: number;
};
containersSize: number;
buildCacheSize: number;
networks: {
total: number;
};
stacks: {
total: number;
running: number;
partial: number;
stopped: number;
};
metrics: {
cpuPercent: number;
memoryPercent: number;
memoryUsed: number;
memoryTotal: number;
} | null;
events: {
total: number;
today: number;
};
topContainers: Array<{
id: string;
name: string;
cpuPercent: number;
memoryPercent: number;
}>;
metricsHistory?: Array<{
cpu_percent: number;
memory_percent: number;
timestamp: string;
}>;
recentEvents?: Array<{
container_name: string;
action: string;
timestamp: string;
}>;
// Progressive loading states
loading?: LoadingStates;
}
export const GET: RequestHandler = async ({ cookies, url }) => {
const auth = await authorize(cookies);
// Support single environment query for real-time updates
const envIdParam = url.searchParams.get('env');
const envIdNum = envIdParam ? parseInt(envIdParam) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('environments', 'view', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
// Early return if no environments configured (fresh install)
if (!await hasEnvironments()) {
return json([]);
}
try {
let environments = await getEnvironments();
// Filter to single environment if specified
if (envIdNum) {
environments = environments.filter(env => env.id === envIdNum);
if (environments.length === 0) {
return json({ error: 'Environment not found' }, { status: 404 });
}
}
// In enterprise mode, filter environments by user's accessible environments
if (auth.authEnabled && auth.isEnterprise && auth.isAuthenticated && !auth.isAdmin) {
const accessibleIds = await auth.getAccessibleEnvironmentIds();
// accessibleIds is null if user has access to all environments
if (accessibleIds !== null) {
environments = environments.filter(env => accessibleIds.includes(env.id));
}
}
// Fetch stats for all environments in parallel
const promises = environments.map(async (env): Promise<EnvironmentStats> => {
const envStats: EnvironmentStats = {
id: env.id,
name: env.name,
host: env.host ?? undefined,
port: env.port ?? undefined,
icon: env.icon || 'globe',
socketPath: env.socketPath ?? undefined,
collectActivity: env.collectActivity,
collectMetrics: env.collectMetrics ?? true,
scannerEnabled: false,
updateCheckEnabled: false,
updateCheckAutoUpdate: false,
labels: parseLabels(env.labels),
connectionType: (env.connectionType as 'socket' | 'direct' | 'hawser-standard' | 'hawser-edge') || 'socket',
online: false,
containers: { total: 0, running: 0, stopped: 0, paused: 0, restarting: 0, unhealthy: 0 },
images: { total: 0, totalSize: 0 },
volumes: { total: 0, totalSize: 0 },
containersSize: 0,
buildCacheSize: 0,
networks: { total: 0 },
stacks: { total: 0, running: 0, partial: 0, stopped: 0 },
metrics: null,
events: { total: 0, today: 0 },
topContainers: []
};
try {
// Check scanner settings - scanner type is stored in 'vulnerability_scanner'
const scannerType = await getEnvSetting('vulnerability_scanner', env.id);
envStats.scannerEnabled = scannerType && scannerType !== 'none';
// Check update check settings
const updateCheckSettings = await getEnvUpdateCheckSettings(env.id);
if (updateCheckSettings && updateCheckSettings.enabled) {
envStats.updateCheckEnabled = true;
envStats.updateCheckAutoUpdate = updateCheckSettings.autoUpdate;
}
// Check if Docker is accessible (with 5 second timeout)
const dockerInfo = await withTimeout(getDockerInfo(env.id), 5000, null);
if (!dockerInfo) {
envStats.error = 'Connection timeout or Docker not accessible';
return envStats;
}
envStats.online = true;
// Fetch all data in parallel (with 10 second timeout per operation)
const [containers, images, volumes, networks, stacks, diskUsage] = await Promise.all([
withTimeout(listContainers(true, env.id).catch(() => []), 10000, []),
withTimeout(listImages(env.id).catch(() => []), 10000, []),
withTimeout(listVolumes(env.id).catch(() => []), 10000, []),
withTimeout(listNetworks(env.id).catch(() => []), 10000, []),
withTimeout(listComposeStacks(env.id).catch(() => []), 10000, []),
withTimeout(getDiskUsage(env.id).catch(() => null), 10000, null)
]);
// Process containers
envStats.containers.total = containers.length;
envStats.containers.running = containers.filter((c: any) => c.state === 'running').length;
envStats.containers.stopped = containers.filter((c: any) => c.state === 'exited').length;
envStats.containers.paused = containers.filter((c: any) => c.state === 'paused').length;
envStats.containers.restarting = containers.filter((c: any) => c.state === 'restarting').length;
envStats.containers.unhealthy = containers.filter((c: any) => c.health === 'unhealthy').length;
// Helper to get valid size (Docker API returns -1 for uncalculated sizes)
const getValidSize = (size: number | undefined | null): number => {
return size && size > 0 ? size : 0;
};
// Process disk usage from /system/df for accurate size data
if (diskUsage) {
// Images: use Size from /system/df
envStats.images.total = diskUsage.Images?.length || images.length;
envStats.images.totalSize = diskUsage.Images?.reduce((sum: number, img: any) => sum + getValidSize(img.Size), 0) || 0;
// Volumes: use UsageData.Size from /system/df
envStats.volumes.total = diskUsage.Volumes?.length || volumes.length;
envStats.volumes.totalSize = diskUsage.Volumes?.reduce((sum: number, vol: any) => sum + getValidSize(vol.UsageData?.Size), 0) || 0;
// Containers: use SizeRw (writable layer size)
envStats.containersSize = diskUsage.Containers?.reduce((sum: number, c: any) => sum + getValidSize(c.SizeRw), 0) || 0;
// Build cache: total size
envStats.buildCacheSize = diskUsage.BuildCache?.reduce((sum: number, bc: any) => sum + getValidSize(bc.Size), 0) || 0;
} else {
// Fallback to original method if /system/df failed
envStats.images.total = images.length;
envStats.images.totalSize = images.reduce((sum: number, img: any) => sum + getValidSize(img.size), 0);
envStats.volumes.total = volumes.length;
envStats.volumes.totalSize = 0;
}
// Process networks
envStats.networks.total = networks.length;
// Process stacks
envStats.stacks.total = stacks.length;
envStats.stacks.running = stacks.filter((s: any) => s.status === 'running').length;
envStats.stacks.partial = stacks.filter((s: any) => s.status === 'partial').length;
envStats.stacks.stopped = stacks.filter((s: any) => s.status === 'stopped').length;
// Get latest metrics and event stats in parallel
const [latestMetrics, eventStats] = await Promise.all([
getLatestHostMetrics(env.id),
getContainerEventStats(env.id)
]);
if (latestMetrics) {
envStats.metrics = {
cpuPercent: latestMetrics.cpuPercent,
memoryPercent: latestMetrics.memoryPercent,
memoryUsed: latestMetrics.memoryUsed,
memoryTotal: latestMetrics.memoryTotal
};
}
envStats.events = {
total: eventStats.total,
today: eventStats.today
};
} catch (error) {
// Convert technical error messages to user-friendly ones
const errorStr = String(error);
if (errorStr.includes('FailedToOpenSocket') || errorStr.includes('ECONNREFUSED')) {
envStats.error = 'Docker socket not accessible';
} else if (errorStr.includes('ECONNRESET') || errorStr.includes('connection was closed')) {
envStats.error = 'Connection lost';
} else if (errorStr.includes('verbose: true') || errorStr.includes('verbose')) {
envStats.error = 'Connection failed';
} else if (errorStr.includes('timeout') || errorStr.includes('Timeout')) {
envStats.error = 'Connection timeout';
} else {
const match = errorStr.match(/^(?:Error:\s*)?([^.!?]+[.!?]?)/);
envStats.error = match ? match[1].trim() : 'Connection error';
}
}
return envStats;
});
const results = await Promise.all(promises);
// Return single object if single env was requested
if (envIdParam && results.length === 1) {
return json(results[0]);
}
return json(results);
} catch (error: any) {
console.error('Failed to get dashboard stats:', error);
return json({ error: 'Failed to get dashboard stats' }, { status: 500 });
}
};
@@ -0,0 +1,531 @@
import type { RequestHandler } from '@sveltejs/kit';
import {
getEnvironments,
getLatestHostMetrics,
getHostMetrics,
getContainerEventStats,
getContainerEvents,
getEnvSetting,
getEnvUpdateCheckSettings
} from '$lib/server/db';
import {
listContainers,
listImages,
listNetworks,
getDockerInfo,
getContainerStats,
getDiskUsage
} from '$lib/server/docker';
import { listComposeStacks } from '$lib/server/stacks';
import { authorize } from '$lib/server/authorize';
import type { EnvironmentStats } from '../+server';
import { parseLabels } from '$lib/utils/label-colors';
// Helper to add timeout to promises
function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T> {
return Promise.race([
promise,
new Promise<T>((resolve) => setTimeout(() => resolve(fallback), ms))
]);
}
// Disk usage cache - getDiskUsage() is very slow (30s timeout) but data changes rarely
// Cache per environment with 5-minute TTL
interface DiskUsageCache {
data: any;
timestamp: number;
}
const diskUsageCache: Map<number, DiskUsageCache> = new Map();
const DISK_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
const MAX_CACHE_SIZE = 100; // Maximum environments to cache
// Cleanup expired cache entries periodically to prevent unbounded growth
// Also limits cache size for environments that were deleted
setInterval(() => {
const now = Date.now();
// Remove expired entries
for (const [envId, cached] of diskUsageCache.entries()) {
if (now - cached.timestamp > DISK_CACHE_TTL_MS * 2) {
diskUsageCache.delete(envId);
}
}
// Enforce max size by removing oldest entries
if (diskUsageCache.size > MAX_CACHE_SIZE) {
const entries = Array.from(diskUsageCache.entries())
.sort((a, b) => a[1].timestamp - b[1].timestamp);
const toRemove = entries.slice(0, entries.length - MAX_CACHE_SIZE);
for (const [envId] of toRemove) {
diskUsageCache.delete(envId);
}
}
}, 10 * 60 * 1000); // Every 10 minutes
async function getCachedDiskUsage(envId: number): Promise<any> {
const cached = diskUsageCache.get(envId);
const now = Date.now();
// Return cached data if still valid
if (cached && (now - cached.timestamp) < DISK_CACHE_TTL_MS) {
return cached.data;
}
// Fetch fresh data with timeout
const data = await withTimeout(getDiskUsage(envId).catch(() => null), 30000, null);
// Only cache successful results - if fetch failed, retry on next request
if (data !== null) {
diskUsageCache.set(envId, { data, timestamp: now });
}
return data;
}
// Limit for per-container stats (reduced from 15 to improve performance)
const TOP_CONTAINERS_LIMIT = 8;
// Calculate CPU percentage from Docker stats (same logic as container stats endpoint)
function calculateCpuPercent(stats: any): number {
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage;
const systemDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage;
const cpuCount = stats.cpu_stats.online_cpus || stats.cpu_stats.cpu_usage.percpu_usage?.length || 1;
if (systemDelta > 0 && cpuDelta > 0) {
return (cpuDelta / systemDelta) * cpuCount * 100;
}
return 0;
}
// Progressive stats loading - returns stats object and emits partial updates via callback
async function getEnvironmentStatsProgressive(
env: any,
onPartialUpdate: (stats: Partial<EnvironmentStats> & { id: number }) => void
): Promise<EnvironmentStats> {
const envStats: EnvironmentStats = {
id: env.id,
name: env.name,
host: env.host ?? undefined,
port: env.port ?? undefined,
icon: env.icon || 'globe',
socketPath: env.socketPath ?? undefined,
collectActivity: env.collectActivity,
collectMetrics: env.collectMetrics ?? true,
scannerEnabled: false,
updateCheckEnabled: false,
updateCheckAutoUpdate: false,
labels: parseLabels(env.labels),
connectionType: (env.connectionType as 'socket' | 'direct' | 'hawser-standard' | 'hawser-edge') || 'socket',
online: false,
containers: { total: 0, running: 0, stopped: 0, paused: 0, restarting: 0, unhealthy: 0 },
images: { total: 0, totalSize: 0 },
volumes: { total: 0, totalSize: 0 },
containersSize: 0,
buildCacheSize: 0,
networks: { total: 0 },
stacks: { total: 0, running: 0, partial: 0, stopped: 0 },
metrics: null,
events: { total: 0, today: 0 },
topContainers: [],
recentEvents: [],
// Loading states for progressive display
loading: {
containers: true,
images: true,
volumes: true,
networks: true,
stacks: true,
diskUsage: true,
topContainers: true
}
};
try {
// Check scanner settings - scanner type is stored in 'vulnerability_scanner'
const scannerType = await getEnvSetting('vulnerability_scanner', env.id);
envStats.scannerEnabled = scannerType && scannerType !== 'none';
// Check update check settings
const updateCheckSettings = await getEnvUpdateCheckSettings(env.id);
if (updateCheckSettings && updateCheckSettings.enabled) {
envStats.updateCheckEnabled = true;
envStats.updateCheckAutoUpdate = updateCheckSettings.autoUpdate;
}
// Check if Docker is accessible (with 5 second timeout)
const dockerInfo = await withTimeout(getDockerInfo(env.id), 5000, null);
if (!dockerInfo) {
envStats.error = 'Connection timeout or Docker not accessible';
envStats.loading = undefined; // Clear loading states on error
// Send offline status to client
onPartialUpdate({
id: env.id,
online: false,
error: envStats.error,
loading: undefined
});
return envStats;
}
envStats.online = true;
// Get all database stats in parallel for better performance
const [latestMetrics, eventStats, recentEventsResult, metricsHistory] = await Promise.all([
getLatestHostMetrics(env.id),
getContainerEventStats(env.id),
getContainerEvents({ environmentId: env.id, limit: 10 }),
getHostMetrics(30, env.id)
]);
if (latestMetrics) {
envStats.metrics = {
cpuPercent: latestMetrics.cpuPercent,
memoryPercent: latestMetrics.memoryPercent,
memoryUsed: latestMetrics.memoryUsed,
memoryTotal: latestMetrics.memoryTotal
};
}
envStats.events = {
total: eventStats.total,
today: eventStats.today
};
if (recentEventsResult.events.length > 0) {
envStats.recentEvents = recentEventsResult.events.map(e => ({
container_name: e.containerName || 'unknown',
action: e.action,
timestamp: e.timestamp
}));
}
if (metricsHistory.length > 0) {
envStats.metricsHistory = metricsHistory.reverse().map(m => ({
cpu_percent: m.cpuPercent,
memory_percent: m.memoryPercent,
timestamp: m.timestamp
}));
}
// Send initial update with DB data and online status
onPartialUpdate({
id: env.id,
online: true,
metrics: envStats.metrics,
events: envStats.events,
recentEvents: envStats.recentEvents,
metricsHistory: envStats.metricsHistory,
scannerEnabled: envStats.scannerEnabled,
updateCheckEnabled: envStats.updateCheckEnabled,
updateCheckAutoUpdate: envStats.updateCheckAutoUpdate,
loading: { ...envStats.loading }
});
// Helper to get valid size
const getValidSize = (size: number | undefined | null): number => {
return size && size > 0 ? size : 0;
};
// PHASE 1: Containers (usually fast)
const containersPromise = withTimeout(listContainers(true, env.id).catch(() => []), 10000, [])
.then(async (containers) => {
envStats.containers.total = containers.length;
envStats.containers.running = containers.filter((c: any) => c.state === 'running').length;
envStats.containers.stopped = containers.filter((c: any) => c.state === 'exited').length;
envStats.containers.paused = containers.filter((c: any) => c.state === 'paused').length;
envStats.containers.restarting = containers.filter((c: any) => c.state === 'restarting').length;
envStats.containers.unhealthy = containers.filter((c: any) => c.health === 'unhealthy').length;
envStats.loading!.containers = false;
onPartialUpdate({
id: env.id,
containers: { ...envStats.containers },
loading: { ...envStats.loading! }
});
return containers;
});
// PHASE 2: Images, Networks, Stacks (medium speed) - run in parallel
const imagesPromise = withTimeout(listImages(env.id).catch(() => []), 10000, [])
.then((images) => {
envStats.images.total = images.length;
envStats.images.totalSize = images.reduce((sum: number, img: any) => sum + getValidSize(img.size), 0);
envStats.loading!.images = false;
onPartialUpdate({
id: env.id,
images: { ...envStats.images },
loading: { ...envStats.loading! }
});
return images;
});
const networksPromise = withTimeout(listNetworks(env.id).catch(() => []), 10000, [])
.then((networks) => {
envStats.networks.total = networks.length;
envStats.loading!.networks = false;
onPartialUpdate({
id: env.id,
networks: { ...envStats.networks },
loading: { ...envStats.loading! }
});
return networks;
});
const stacksPromise = withTimeout(listComposeStacks(env.id).catch(() => []), 10000, [])
.then((stacks) => {
envStats.stacks.total = stacks.length;
envStats.stacks.running = stacks.filter((s: any) => s.status === 'running').length;
envStats.stacks.partial = stacks.filter((s: any) => s.status === 'partial').length;
envStats.stacks.stopped = stacks.filter((s: any) => s.status === 'stopped').length;
envStats.loading!.stacks = false;
onPartialUpdate({
id: env.id,
stacks: { ...envStats.stacks },
loading: { ...envStats.loading! }
});
return stacks;
});
// PHASE 3: Disk usage (slow - includes volumes) - uses cache for better performance
const diskUsagePromise = getCachedDiskUsage(env.id)
.then((diskUsage) => {
if (diskUsage) {
// Update images with disk usage data (more accurate)
envStats.images.total = diskUsage.Images?.length || envStats.images.total;
envStats.images.totalSize = diskUsage.Images?.reduce((sum: number, img: any) => sum + getValidSize(img.Size), 0) || envStats.images.totalSize;
// Volumes from disk usage
envStats.volumes.total = diskUsage.Volumes?.length || 0;
envStats.volumes.totalSize = diskUsage.Volumes?.reduce((sum: number, vol: any) => sum + getValidSize(vol.UsageData?.Size), 0) || 0;
// Containers disk size
envStats.containersSize = diskUsage.Containers?.reduce((sum: number, c: any) => sum + getValidSize(c.SizeRw), 0) || 0;
// Build cache
envStats.buildCacheSize = diskUsage.BuildCache?.reduce((sum: number, bc: any) => sum + getValidSize(bc.Size), 0) || 0;
}
envStats.loading!.volumes = false;
envStats.loading!.diskUsage = false;
onPartialUpdate({
id: env.id,
images: { ...envStats.images },
volumes: { ...envStats.volumes },
containersSize: envStats.containersSize,
buildCacheSize: envStats.buildCacheSize,
loading: { ...envStats.loading! }
});
return diskUsage;
});
// PHASE 4: Top containers (slow - requires per-container stats)
// Limited to TOP_CONTAINERS_LIMIT containers to reduce API calls
const topContainersPromise = containersPromise.then(async (containers) => {
const runningContainersList = containers.filter((c: any) => c.state === 'running');
const topContainersPromises = runningContainersList.slice(0, TOP_CONTAINERS_LIMIT).map(async (container: any) => {
try {
// 5 second timeout per container (increased from 2s for Hawser environments)
const stats = await withTimeout(
getContainerStats(container.id, env.id) as Promise<any>,
5000,
null
);
if (!stats) return null;
const cpuPercent = calculateCpuPercent(stats);
const memoryUsage = stats.memory_stats?.usage || 0;
const memoryLimit = stats.memory_stats?.limit || 1;
const memoryPercent = (memoryUsage / memoryLimit) * 100;
return {
name: container.name,
cpuPercent: Math.round(cpuPercent * 100) / 100,
memoryPercent: Math.round(memoryPercent * 100) / 100
};
} catch {
return null;
}
});
const topContainersResults = await Promise.all(topContainersPromises);
envStats.topContainers = topContainersResults
.filter((c): c is { name: string; cpuPercent: number; memoryPercent: number } => c !== null)
.sort((a, b) => b.cpuPercent - a.cpuPercent)
.slice(0, 10);
envStats.loading!.topContainers = false;
onPartialUpdate({
id: env.id,
topContainers: [...envStats.topContainers],
loading: { ...envStats.loading! }
});
return envStats.topContainers;
});
// Wait for all to complete
await Promise.all([
containersPromise,
imagesPromise,
networksPromise,
stacksPromise,
diskUsagePromise,
topContainersPromise
]);
// Clear loading states when complete
envStats.loading = undefined;
} catch (error) {
// Convert technical error messages to user-friendly ones
const errorStr = String(error);
if (errorStr.includes('not connected') || errorStr.includes('Edge agent')) {
envStats.error = 'Agent not connected';
} else if (errorStr.includes('FailedToOpenSocket') || errorStr.includes('ECONNREFUSED')) {
envStats.error = 'Docker socket not accessible';
} else if (errorStr.includes('ECONNRESET') || errorStr.includes('connection was closed')) {
envStats.error = 'Connection lost';
} else if (errorStr.includes('verbose: true') || errorStr.includes('verbose')) {
envStats.error = 'Connection failed';
} else if (errorStr.includes('timeout') || errorStr.includes('Timeout')) {
envStats.error = 'Connection timeout';
} else {
// Extract just the error message, not the full stack/details
const match = errorStr.match(/^(?:Error:\s*)?([^.!?]+[.!?]?)/);
envStats.error = match ? match[1].trim() : 'Connection error';
}
envStats.loading = undefined;
// Send offline status to client
onPartialUpdate({
id: env.id,
online: false,
error: envStats.error,
loading: undefined
});
}
return envStats;
}
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('environments', 'view')) {
return new Response(JSON.stringify({ error: 'Permission denied' }), {
status: 403,
headers: { 'Content-Type': 'application/json' }
});
}
let environments = await getEnvironments();
// In enterprise mode, filter environments by user's accessible environments
if (auth.authEnabled && auth.isEnterprise && auth.isAuthenticated && !auth.isAdmin) {
const accessibleIds = await auth.getAccessibleEnvironmentIds();
// accessibleIds is null if user has access to all environments
if (accessibleIds !== null) {
environments = environments.filter(env => accessibleIds.includes(env.id));
}
}
// Create a readable stream that sends environment stats progressively
let controllerClosed = false;
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
// Safe enqueue that checks if controller is still open
const safeEnqueue = (data: string) => {
if (!controllerClosed) {
try {
controller.enqueue(encoder.encode(data));
} catch {
controllerClosed = true;
}
}
};
// First, send the list of environments so the UI can show skeletons with loading states
const envList = environments.map(env => ({
id: env.id,
name: env.name,
host: env.host ?? undefined,
port: env.port ?? undefined,
icon: env.icon || 'globe',
socketPath: env.socketPath ?? undefined,
collectActivity: env.collectActivity,
collectMetrics: env.collectMetrics ?? true,
labels: parseLabels(env.labels),
connectionType: (env.connectionType as 'socket' | 'direct' | 'hawser-standard' | 'hawser-edge') || 'socket',
// Initial loading state for all sections
loading: {
containers: true,
images: true,
volumes: true,
networks: true,
stacks: true,
diskUsage: true,
topContainers: true
}
}));
safeEnqueue(`event: environments\ndata: ${JSON.stringify(envList)}\n\n`);
// Fetch stats for each environment with progressive updates
const promises = environments.map(async (env) => {
try {
await getEnvironmentStatsProgressive(env, (partialStats) => {
// Send partial update as it arrives
safeEnqueue(`event: partial\ndata: ${JSON.stringify(partialStats)}\n\n`);
});
// Send final complete stats event for this environment
safeEnqueue(`event: complete\ndata: ${JSON.stringify({ id: env.id })}\n\n`);
} catch (error) {
console.error(`Failed to get stats for ${env.name}:`, error);
// Convert technical error to user-friendly message
const errorStr = String(error);
let friendlyError = 'Connection error';
if (errorStr.includes('FailedToOpenSocket') || errorStr.includes('ECONNREFUSED')) {
friendlyError = 'Docker socket not accessible';
} else if (errorStr.includes('ECONNRESET') || errorStr.includes('connection was closed')) {
friendlyError = 'Connection lost';
} else if (errorStr.includes('verbose') || errorStr.includes('typo')) {
friendlyError = 'Connection failed';
} else if (errorStr.includes('timeout') || errorStr.includes('Timeout')) {
friendlyError = 'Connection timeout';
}
safeEnqueue(`event: error\ndata: ${JSON.stringify({ id: env.id, error: friendlyError })}\n\n`);
}
});
// Wait for all to complete
await Promise.all(promises);
// Send done event and close
if (!controllerClosed) {
safeEnqueue(`event: done\ndata: {}\n\n`);
try {
controller.close();
} catch {
// Already closed
}
}
},
cancel() {
// Called when the client disconnects
controllerClosed = true;
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
}
});
};
+26
View File
@@ -0,0 +1,26 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import dependencies from '$lib/data/dependencies.json';
// External tools used by Dockhand (Docker images)
const externalTools = [
{
name: 'anchore/grype',
version: 'latest',
license: 'Apache-2.0',
repository: 'https://github.com/anchore/grype'
},
{
name: 'aquasec/trivy',
version: 'latest',
license: 'Apache-2.0',
repository: 'https://github.com/aquasecurity/trivy'
}
];
export const GET: RequestHandler = async () => {
// Combine npm dependencies with external tools, exclude dockhand itself
const allDependencies = [...dependencies, ...externalTools]
.filter((dep) => dep.name !== 'dockhand')
.sort((a, b) => a.name.localeCompare(b.name));
return json(allDependencies);
};
+129
View File
@@ -0,0 +1,129 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getEnvironments, createEnvironment, assignUserRole, getRoleByName, getEnvironmentPublicIps, setEnvironmentPublicIp, getEnvUpdateCheckSettings, getEnvironmentTimezone, type Environment } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager';
import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-colors';
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('environments', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
let environments = await getEnvironments();
// In enterprise mode, filter environments by user's accessible environments
if (auth.authEnabled && auth.isEnterprise && auth.isAuthenticated && !auth.isAdmin) {
const accessibleIds = await auth.getAccessibleEnvironmentIds();
// accessibleIds is null if user has access to all environments
if (accessibleIds !== null) {
environments = environments.filter(env => accessibleIds.includes(env.id));
}
}
// Get public IPs for all environments
const publicIps = await getEnvironmentPublicIps();
// Get update check settings for all environments
const updateCheckSettingsMap = new Map<number, { enabled: boolean; autoUpdate: boolean }>();
for (const env of environments) {
const settings = await getEnvUpdateCheckSettings(env.id);
if (settings && settings.enabled) {
updateCheckSettingsMap.set(env.id, { enabled: true, autoUpdate: settings.autoUpdate });
}
}
// Parse labels from JSON string to array, add public IPs, update check settings, and timezone
const envWithParsedLabels = await Promise.all(environments.map(async env => {
const updateSettings = updateCheckSettingsMap.get(env.id);
const timezone = await getEnvironmentTimezone(env.id);
return {
...env,
labels: parseLabels(env.labels as string | null),
publicIp: publicIps[env.id.toString()] || null,
updateCheckEnabled: updateSettings?.enabled || false,
updateCheckAutoUpdate: updateSettings?.autoUpdate || false,
timezone
};
}));
return json(envWithParsedLabels);
} catch (error) {
console.error('Failed to get environments:', error);
return json({ error: 'Failed to get environments' }, { status: 500 });
}
};
export const POST: RequestHandler = async ({ request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('environments', 'create')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const data = await request.json();
if (!data.name) {
return json({ error: 'Name is required' }, { status: 400 });
}
// Host is required for direct and hawser-standard connections
const connectionType = data.connectionType || 'socket';
if ((connectionType === 'direct' || connectionType === 'hawser-standard') && !data.host) {
return json({ error: 'Host is required for this connection type' }, { status: 400 });
}
// Validate labels
const labels = Array.isArray(data.labels) ? data.labels.slice(0, MAX_LABELS) : [];
const env = await createEnvironment({
name: data.name,
host: data.host,
port: data.port || 2375,
protocol: data.protocol || 'http',
tlsCa: data.tlsCa,
tlsCert: data.tlsCert,
tlsKey: data.tlsKey,
icon: data.icon || 'globe',
socketPath: data.socketPath || '/var/run/docker.sock',
collectActivity: data.collectActivity !== false,
collectMetrics: data.collectMetrics !== false,
highlightChanges: data.highlightChanges !== false,
labels: serializeLabels(labels),
connectionType: connectionType,
hawserToken: data.hawserToken
});
// Save public IP if provided
if (data.publicIp) {
await setEnvironmentPublicIp(env.id, data.publicIp);
}
// Notify subprocesses to pick up the new environment
refreshSubprocessEnvironments();
// Auto-assign Admin role to creator (Enterprise only)
if (auth.isEnterprise && auth.authEnabled && auth.isAuthenticated && !auth.isAdmin) {
const user = auth.user;
if (user) {
try {
const adminRole = await getRoleByName('Admin');
if (adminRole) {
await assignUserRole(user.id, adminRole.id, env.id);
}
} catch (roleError) {
// Log but don't fail - environment was created successfully
console.error(`Failed to auto-assign Admin role to user ${user.id} for environment ${env.id}:`, roleError);
}
}
}
return json(env);
} catch (error) {
console.error('Failed to create environment:', error);
const message = error instanceof Error ? error.message : 'Failed to create environment';
return json({ error: message }, { status: 500 });
}
};
+158
View File
@@ -0,0 +1,158 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getEnvironment, updateEnvironment, deleteEnvironment, getEnvironmentPublicIps, setEnvironmentPublicIp, deleteEnvironmentPublicIp, deleteEnvUpdateCheckSettings, getGitStacksForEnvironmentOnly, deleteGitStack } from '$lib/server/db';
import { clearDockerClientCache } from '$lib/server/docker';
import { deleteGitStackFiles } from '$lib/server/git';
import { authorize } from '$lib/server/authorize';
import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager';
import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-colors';
import { unregisterSchedule } from '$lib/server/scheduler';
import { closeEdgeConnection } from '$lib/server/hawser';
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('environments', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const id = parseInt(params.id);
const env = await getEnvironment(id);
if (!env) {
return json({ error: 'Environment not found' }, { status: 404 });
}
// Get public IP for this environment
const publicIps = await getEnvironmentPublicIps();
const publicIp = publicIps[id.toString()] || null;
// Parse labels from JSON string to array
return json({
...env,
labels: parseLabels(env.labels as string | null),
publicIp
});
} catch (error) {
console.error('Failed to get environment:', error);
return json({ error: 'Failed to get environment' }, { status: 500 });
}
};
export const PUT: RequestHandler = async ({ params, request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('environments', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const id = parseInt(params.id);
const data = await request.json();
// Clear cached Docker client before updating
clearDockerClientCache(id);
// Handle labels - only update if provided in the request
const labels = data.labels !== undefined
? serializeLabels(Array.isArray(data.labels) ? data.labels.slice(0, MAX_LABELS) : [])
: undefined;
const env = await updateEnvironment(id, {
name: data.name,
host: data.host,
port: data.port,
protocol: data.protocol,
tlsCa: data.tlsCa,
tlsCert: data.tlsCert,
tlsKey: data.tlsKey,
icon: data.icon,
socketPath: data.socketPath,
collectActivity: data.collectActivity,
collectMetrics: data.collectMetrics,
highlightChanges: data.highlightChanges,
labels: labels,
connectionType: data.connectionType,
hawserToken: data.hawserToken
});
if (!env) {
return json({ error: 'Environment not found' }, { status: 404 });
}
// Notify subprocesses if collectActivity or collectMetrics setting changed
if (data.collectActivity !== undefined || data.collectMetrics !== undefined) {
refreshSubprocessEnvironments();
}
// Handle public IP - update if provided in request
if (data.publicIp !== undefined) {
await setEnvironmentPublicIp(id, data.publicIp || null);
}
// Get current public IP for response
const publicIps = await getEnvironmentPublicIps();
const publicIp = publicIps[id.toString()] || null;
// Parse labels from JSON string to array
return json({
...env,
labels: parseLabels(env.labels as string | null),
publicIp
});
} catch (error) {
console.error('Failed to update environment:', error);
return json({ error: 'Failed to update environment' }, { status: 500 });
}
};
export const DELETE: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('environments', 'delete')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const id = parseInt(params.id);
// Close Edge connection if this is a Hawser Edge environment
// This rejects any pending requests and closes the WebSocket
closeEdgeConnection(id);
// Clear cached Docker client before deleting
clearDockerClientCache(id);
// Clean up git stacks for this environment
const gitStacks = await getGitStacksForEnvironmentOnly(id);
for (const stack of gitStacks) {
// Unregister schedule if auto-update was enabled
if (stack.autoUpdate) {
unregisterSchedule(stack.id, 'git_stack_sync');
}
// Delete git stack files from filesystem
deleteGitStackFiles(stack.id);
// Delete git stack from database
await deleteGitStack(stack.id);
}
const success = await deleteEnvironment(id);
if (!success) {
return json({ error: 'Cannot delete this environment' }, { status: 400 });
}
// Clean up public IP entry for this environment
await deleteEnvironmentPublicIp(id);
// Clean up update check settings and unregister schedule
await deleteEnvUpdateCheckSettings(id);
unregisterSchedule(id, 'env_update_check');
// Notify subprocesses to stop collecting from deleted environment
refreshSubprocessEnvironments();
return json({ success: true });
} catch (error) {
console.error('Failed to delete environment:', error);
return json({ error: 'Failed to delete environment' }, { status: 500 });
}
};
@@ -0,0 +1,78 @@
import { json } from '@sveltejs/kit';
import {
getEnvironmentNotifications,
createEnvironmentNotification,
getEnvironment,
getNotificationSettings,
type NotificationEventType
} from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import type { RequestHandler } from './$types';
// GET /api/environments/[id]/notifications - List all notification configurations for an environment
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('notifications', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const envId = parseInt(params.id);
if (isNaN(envId)) {
return json({ error: 'Invalid environment ID' }, { status: 400 });
}
const env = await getEnvironment(envId);
if (!env) {
return json({ error: 'Environment not found' }, { status: 404 });
}
try {
const notifications = await getEnvironmentNotifications(envId);
return json(notifications);
} catch (error) {
console.error('Error fetching environment notifications:', error);
return json({ error: 'Failed to fetch environment notifications' }, { status: 500 });
}
};
// POST /api/environments/[id]/notifications - Add a notification channel to an environment
export const POST: RequestHandler = async ({ params, request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('notifications', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const envId = parseInt(params.id);
if (isNaN(envId)) {
return json({ error: 'Invalid environment ID' }, { status: 400 });
}
const env = await getEnvironment(envId);
if (!env) {
return json({ error: 'Environment not found' }, { status: 404 });
}
try {
const body = await request.json();
const { notificationId, enabled, eventTypes } = body;
if (!notificationId) {
return json({ error: 'notificationId is required' }, { status: 400 });
}
const notification = await createEnvironmentNotification({
environmentId: envId,
notificationId,
enabled: enabled !== false,
eventTypes: eventTypes as NotificationEventType[]
});
return json(notification);
} catch (error: any) {
console.error('Error creating environment notification:', error);
if (error.message?.includes('UNIQUE constraint failed')) {
return json({ error: 'This notification channel is already configured for this environment' }, { status: 409 });
}
return json({ error: error.message || 'Failed to create environment notification' }, { status: 500 });
}
};
@@ -0,0 +1,103 @@
import { json } from '@sveltejs/kit';
import {
getEnvironmentNotification,
updateEnvironmentNotification,
deleteEnvironmentNotification,
getEnvironment,
type NotificationEventType
} from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import type { RequestHandler } from './$types';
// GET /api/environments/[id]/notifications/[notificationId] - Get a specific environment notification
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('notifications', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const envId = parseInt(params.id);
const notifId = parseInt(params.notificationId);
if (isNaN(envId) || isNaN(notifId)) {
return json({ error: 'Invalid ID' }, { status: 400 });
}
const env = await getEnvironment(envId);
if (!env) {
return json({ error: 'Environment not found' }, { status: 404 });
}
try {
const notification = await getEnvironmentNotification(envId, notifId);
if (!notification) {
return json({ error: 'Environment notification not found' }, { status: 404 });
}
return json(notification);
} catch (error) {
console.error('Error fetching environment notification:', error);
return json({ error: 'Failed to fetch environment notification' }, { status: 500 });
}
};
// PUT /api/environments/[id]/notifications/[notificationId] - Update an environment notification
export const PUT: RequestHandler = async ({ params, request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('notifications', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const envId = parseInt(params.id);
const notifId = parseInt(params.notificationId);
if (isNaN(envId) || isNaN(notifId)) {
return json({ error: 'Invalid ID' }, { status: 400 });
}
const env = await getEnvironment(envId);
if (!env) {
return json({ error: 'Environment not found' }, { status: 404 });
}
try {
const body = await request.json();
const { enabled, eventTypes } = body;
const notification = await updateEnvironmentNotification(envId, notifId, {
enabled,
eventTypes: eventTypes as NotificationEventType[]
});
if (!notification) {
return json({ error: 'Environment notification not found' }, { status: 404 });
}
return json(notification);
} catch (error: any) {
console.error('Error updating environment notification:', error);
return json({ error: error.message || 'Failed to update environment notification' }, { status: 500 });
}
};
// DELETE /api/environments/[id]/notifications/[notificationId] - Remove a notification from an environment
export const DELETE: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('notifications', 'delete')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const envId = parseInt(params.id);
const notifId = parseInt(params.notificationId);
if (isNaN(envId) || isNaN(notifId)) {
return json({ error: 'Invalid ID' }, { status: 400 });
}
try {
const deleted = await deleteEnvironmentNotification(envId, notifId);
if (!deleted) {
return json({ error: 'Environment notification not found' }, { status: 404 });
}
return json({ success: true });
} catch (error: any) {
console.error('Error deleting environment notification:', error);
return json({ error: error.message || 'Failed to delete environment notification' }, { status: 500 });
}
};
@@ -0,0 +1,138 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getEnvironment, updateEnvironment } from '$lib/server/db';
import { getDockerInfo } from '$lib/server/docker';
import { edgeConnections, isEdgeConnected } from '$lib/server/hawser';
export const POST: RequestHandler = async ({ params }) => {
try {
const id = parseInt(params.id);
const env = await getEnvironment(id);
if (!env) {
return json({ error: 'Environment not found' }, { status: 404 });
}
// Edge mode - check connection status immediately without blocking
if (env.connectionType === 'hawser-edge') {
const edgeConn = edgeConnections.get(id);
const connected = isEdgeConnected(id);
if (!connected) {
console.log(`[Test] Edge environment ${id} (${env.name}) - agent not connected`);
return json({
success: false,
error: 'Edge agent is not connected',
isEdgeMode: true,
hawser: env.hawserVersion ? {
hawserVersion: env.hawserVersion,
agentId: env.hawserAgentId,
agentName: env.hawserAgentName
} : null
}, { status: 200 });
}
// Agent is connected - try to get Docker info with shorter timeout
console.log(`[Test] Edge environment ${id} (${env.name}) - agent connected, testing Docker...`);
try {
const info = await getDockerInfo(env.id) as any;
return json({
success: true,
info: {
serverVersion: info.ServerVersion,
containers: info.Containers,
images: info.Images,
name: info.Name
},
isEdgeMode: true,
hawser: edgeConn ? {
hawserVersion: edgeConn.agentVersion,
agentId: edgeConn.agentId,
agentName: edgeConn.agentName,
hostname: edgeConn.hostname,
dockerVersion: edgeConn.dockerVersion,
capabilities: edgeConn.capabilities
} : null
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Docker API call failed';
console.error(`[Test] Edge environment ${id} Docker test failed:`, message);
return json({
success: false,
error: message,
isEdgeMode: true,
hawser: edgeConn ? {
hawserVersion: edgeConn.agentVersion,
agentId: edgeConn.agentId,
agentName: edgeConn.agentName
} : null
}, { status: 200 });
}
}
const info = await getDockerInfo(env.id) as any;
// For Hawser Standard mode, fetch Hawser info (Edge mode handled above with early return)
let hawserInfo = null;
if (env.connectionType === 'hawser-standard') {
// Standard mode: fetch via HTTP
try {
const protocol = env.useTls ? 'https' : 'http';
const headers: Record<string, string> = {};
if (env.hawserToken) {
headers['X-Hawser-Token'] = env.hawserToken;
}
const hawserResp = await fetch(`${protocol}://${env.host}:${env.port || 2376}/_hawser/info`, {
headers,
signal: AbortSignal.timeout(5000)
});
if (hawserResp.ok) {
hawserInfo = await hawserResp.json();
// Save hawser info to database
if (hawserInfo?.hawserVersion) {
await updateEnvironment(id, {
hawserVersion: hawserInfo.hawserVersion,
hawserAgentId: hawserInfo.agentId,
hawserAgentName: hawserInfo.agentName,
hawserLastSeen: new Date().toISOString()
});
}
}
} catch {
// Hawser info fetch failed, continue without it
}
}
return json({
success: true,
info: {
serverVersion: info.ServerVersion,
containers: info.Containers,
images: info.Images,
name: info.Name
},
hawser: hawserInfo
});
} catch (error) {
const rawMessage = error instanceof Error ? error.message : 'Connection failed';
console.error('Failed to test connection:', rawMessage);
// Provide more helpful error messages for Hawser connections
let message = rawMessage;
if (rawMessage.includes('401') || rawMessage.toLowerCase().includes('unauthorized')) {
message = 'Invalid token - check that the Hawser token matches';
} else if (rawMessage.includes('403') || rawMessage.toLowerCase().includes('forbidden')) {
message = 'Access forbidden - check token permissions';
} else if (rawMessage.includes('ECONNREFUSED') || rawMessage.includes('Connection refused')) {
message = 'Connection refused - is Hawser running?';
} else if (rawMessage.includes('ETIMEDOUT') || rawMessage.includes('timeout') || rawMessage.includes('Timeout')) {
message = 'Connection timed out - check host and port';
} else if (rawMessage.includes('ENOTFOUND') || rawMessage.includes('getaddrinfo')) {
message = 'Host not found - check the hostname';
} else if (rawMessage.includes('EHOSTUNREACH')) {
message = 'Host unreachable - check network connectivity';
}
return json({ success: false, error: message }, { status: 200 });
}
};
@@ -0,0 +1,75 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import {
getEnvironmentTimezone,
setEnvironmentTimezone,
getEnvironment
} from '$lib/server/db';
import { refreshSchedulesForEnvironment } from '$lib/server/scheduler';
/**
* Get timezone for an environment.
*/
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('environments', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const id = parseInt(params.id);
// Verify environment exists
const env = await getEnvironment(id);
if (!env) {
return json({ error: 'Environment not found' }, { status: 404 });
}
const timezone = await getEnvironmentTimezone(id);
return json({ timezone });
} catch (error) {
console.error('Failed to get environment timezone:', error);
return json({ error: 'Failed to get environment timezone' }, { status: 500 });
}
};
/**
* Set timezone for an environment.
*/
export const POST: RequestHandler = async ({ params, request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('environments', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const id = parseInt(params.id);
// Verify environment exists
const env = await getEnvironment(id);
if (!env) {
return json({ error: 'Environment not found' }, { status: 404 });
}
const data = await request.json();
const timezone = data.timezone || 'UTC';
// Validate timezone
const validTimezones = Intl.supportedValuesOf('timeZone');
if (!validTimezones.includes(timezone) && timezone !== 'UTC') {
return json({ error: 'Invalid timezone' }, { status: 400 });
}
await setEnvironmentTimezone(id, timezone);
// Refresh all schedules for this environment to use the new timezone
await refreshSchedulesForEnvironment(id);
return json({ success: true, timezone });
} catch (error) {
console.error('Failed to set environment timezone:', error);
return json({ error: 'Failed to set environment timezone' }, { status: 500 });
}
};
@@ -0,0 +1,87 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import {
getEnvUpdateCheckSettings,
setEnvUpdateCheckSettings,
getEnvironment
} from '$lib/server/db';
import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler';
/**
* Get update check settings for an environment.
*/
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('environments', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const id = parseInt(params.id);
// Verify environment exists
const env = await getEnvironment(id);
if (!env) {
return json({ error: 'Environment not found' }, { status: 404 });
}
const settings = await getEnvUpdateCheckSettings(id);
return json({
settings: settings || {
enabled: false,
cron: '0 4 * * *',
autoUpdate: false,
vulnerabilityCriteria: 'never'
}
});
} catch (error) {
console.error('Failed to get update check settings:', error);
return json({ error: 'Failed to get update check settings' }, { status: 500 });
}
};
/**
* Save update check settings for an environment.
*/
export const POST: RequestHandler = async ({ params, request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('environments', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const id = parseInt(params.id);
// Verify environment exists
const env = await getEnvironment(id);
if (!env) {
return json({ error: 'Environment not found' }, { status: 404 });
}
const data = await request.json();
const settings = {
enabled: data.enabled ?? false,
cron: data.cron || '0 4 * * *',
autoUpdate: data.autoUpdate ?? false,
vulnerabilityCriteria: data.vulnerabilityCriteria || 'never'
};
// Save settings to database
await setEnvUpdateCheckSettings(id, settings);
// Register or unregister schedule based on enabled state
if (settings.enabled) {
await registerSchedule(id, 'env_update_check', id);
} else {
unregisterSchedule(id, 'env_update_check');
}
return json({ success: true, settings });
} catch (error) {
console.error('Failed to save update check settings:', error);
return json({ error: 'Failed to save update check settings' }, { status: 500 });
}
};
@@ -0,0 +1,46 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { existsSync } from 'node:fs';
import { homedir } from 'node:os';
interface DetectedSocket {
path: string;
name: string;
exists: boolean;
}
/**
* Detect available Docker sockets on the system
*/
export const GET: RequestHandler = async () => {
const home = homedir();
// Common socket paths to check
const socketPaths: { path: string; name: string }[] = [
{ path: '/var/run/docker.sock', name: 'Docker (default)' },
{ path: `${home}/.docker/run/docker.sock`, name: 'Docker Desktop' },
{ path: `${home}/.orbstack/run/docker.sock`, name: 'OrbStack' },
{ path: '/run/docker.sock', name: 'Docker (alternate)' },
{ path: `${home}/.colima/default/docker.sock`, name: 'Colima' },
{ path: `${home}/.rd/docker.sock`, name: 'Rancher Desktop' },
{ path: '/run/user/1000/podman/podman.sock', name: 'Podman (user 1000)' },
{ path: `${home}/.local/share/containers/podman/machine/podman.sock`, name: 'Podman Machine' },
];
const detected: DetectedSocket[] = [];
for (const socket of socketPaths) {
if (existsSync(socket.path)) {
detected.push({
path: socket.path,
name: socket.name,
exists: true
});
}
}
return json({
sockets: detected,
homedir: home
});
};
+201
View File
@@ -0,0 +1,201 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
interface TestConnectionRequest {
connectionType: 'socket' | 'direct' | 'hawser-standard' | 'hawser-edge';
socketPath?: string;
host?: string;
port?: number;
protocol?: string;
tlsCa?: string;
tlsCert?: string;
tlsKey?: string;
tlsSkipVerify?: boolean;
hawserToken?: string;
}
/**
* Test Docker connection with provided configuration (without saving to database)
*/
export const POST: RequestHandler = async ({ request }) => {
try {
const config: TestConnectionRequest = await request.json();
// Build fetch options based on connection type
let response: Response;
if (config.connectionType === 'socket') {
const socketPath = config.socketPath || '/var/run/docker.sock';
response = await fetch('http://localhost/info', {
// @ts-ignore - Bun supports unix socket
unix: socketPath,
signal: AbortSignal.timeout(10000)
});
} else if (config.connectionType === 'hawser-edge') {
// Edge mode - cannot test directly, agent connects to us
return json({
success: true,
info: {
message: 'Edge mode environments are tested when the agent connects'
},
isEdgeMode: true
});
} else {
// Direct or Hawser Standard - HTTP/HTTPS connection
const protocol = config.protocol || 'http';
const host = config.host;
const port = config.port || 2375;
if (!host) {
return json({ success: false, error: 'Host is required' }, { status: 400 });
}
const url = `${protocol}://${host}:${port}/info`;
const headers: Record<string, string> = {
'Content-Type': 'application/json'
};
// Add Hawser token if present
if (config.connectionType === 'hawser-standard' && config.hawserToken) {
headers['X-Hawser-Token'] = config.hawserToken;
}
// For HTTPS with custom CA or skip verification, use subprocess to avoid Vite dev server TLS issues
if (protocol === 'https' && (config.tlsCa || config.tlsSkipVerify)) {
const fs = await import('node:fs');
let tempCaPath = '';
// Clean the certificate - remove leading/trailing whitespace from each line
let cleanedCa = '';
if (config.tlsCa && !config.tlsSkipVerify) {
cleanedCa = config.tlsCa
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)
.join('\n');
tempCaPath = `/tmp/dockhand-ca-${Date.now()}.pem`;
fs.writeFileSync(tempCaPath, cleanedCa);
}
// Build Bun script that runs outside Vite's process (Vite interferes with TLS)
const tlsConfig = config.tlsSkipVerify
? `tls: { rejectUnauthorized: false }`
: `tls: { ca: await Bun.file('${tempCaPath}').text() }`;
const scriptContent = `
const response = await fetch('https://${host}:${port}/info', {
headers: ${JSON.stringify(headers)},
${tlsConfig}
});
const body = await response.text();
console.log(JSON.stringify({ status: response.status, body }));
`;
const scriptPath = `/tmp/dockhand-test-${Date.now()}.ts`;
fs.writeFileSync(scriptPath, scriptContent);
const proc = Bun.spawn(['bun', scriptPath], { stdout: 'pipe', stderr: 'pipe' });
const output = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();
// Cleanup temp files
if (tempCaPath) {
try { fs.unlinkSync(tempCaPath); } catch {}
}
try { fs.unlinkSync(scriptPath); } catch {}
if (!output.trim()) {
throw new Error(stderr || 'Empty response from TLS test subprocess');
}
const result = JSON.parse(output.trim());
if (result.error) {
throw new Error(result.error);
}
response = new Response(result.body, {
status: result.status,
headers: { 'Content-Type': 'application/json' }
});
} else {
response = await fetch(url, {
headers,
signal: AbortSignal.timeout(10000)
});
}
}
if (!response.ok) {
const error = await response.text();
throw new Error(`Docker API error: ${response.status} - ${error}`);
}
const info = await response.json();
// For Hawser Standard, also try to fetch Hawser info
let hawserInfo = null;
if (config.connectionType === 'hawser-standard' && config.host) {
try {
const protocol = config.protocol || 'http';
const headers: Record<string, string> = {};
if (config.hawserToken) {
headers['X-Hawser-Token'] = config.hawserToken;
}
const hawserResp = await fetch(
`${protocol}://${config.host}:${config.port || 2375}/_hawser/info`,
{
headers,
signal: AbortSignal.timeout(5000)
}
);
if (hawserResp.ok) {
hawserInfo = await hawserResp.json();
}
} catch {
// Hawser info fetch failed, continue without it
}
}
return json({
success: true,
info: {
serverVersion: info.ServerVersion,
containers: info.Containers,
images: info.Images,
name: info.Name
},
hawser: hawserInfo
});
} catch (error) {
const rawMessage = error instanceof Error ? error.message : 'Connection failed';
console.error('Failed to test connection:', rawMessage);
// Provide more helpful error messages
let message = rawMessage;
if (rawMessage.includes('401') || rawMessage.toLowerCase().includes('unauthorized')) {
message = 'Invalid token - check that the Hawser token matches';
} else if (rawMessage.includes('403') || rawMessage.toLowerCase().includes('forbidden')) {
message = 'Access forbidden - check token permissions';
} else if (rawMessage.includes('ECONNREFUSED') || rawMessage.includes('Connection refused')) {
message = 'Connection refused - is Docker/Hawser running?';
} else if (rawMessage.includes('ETIMEDOUT') || rawMessage.includes('timeout') || rawMessage.includes('Timeout')) {
message = 'Connection timed out - check host and port';
} else if (rawMessage.includes('ENOTFOUND') || rawMessage.includes('getaddrinfo')) {
message = 'Host not found - check the hostname';
} else if (rawMessage.includes('EHOSTUNREACH')) {
message = 'Host unreachable - check network connectivity';
} else if (rawMessage.includes('ENOENT') || rawMessage.includes('no such file')) {
message = 'Socket not found - check the socket path';
} else if (rawMessage.includes('EACCES') || rawMessage.includes('permission denied')) {
message = 'Permission denied - check socket permissions';
} else if (rawMessage.includes('typo in the url') || rawMessage.includes('Was there a typo')) {
message = 'Connection failed - check host and port';
} else if (rawMessage.includes('self signed certificate') || rawMessage.includes('UNABLE_TO_VERIFY_LEAF_SIGNATURE')) {
message = 'TLS certificate error - provide CA certificate for self-signed certs';
} else if (rawMessage.includes('certificate') || rawMessage.includes('SSL') || rawMessage.includes('TLS')) {
message = 'TLS/SSL error - check certificate configuration';
}
return json({ success: false, error: message }, { status: 200 });
}
};
+137
View File
@@ -0,0 +1,137 @@
import type { RequestHandler } from './$types';
import { getDockerEvents } from '$lib/server/docker';
import { getEnvironment } from '$lib/server/db';
export const GET: RequestHandler = async ({ url }) => {
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Early return if no environment specified
if (!envIdNum) {
return new Response(
`event: info\ndata: ${JSON.stringify({ message: 'No environment selected' })}\n\n`,
{
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache'
}
}
);
}
// Check if this is an edge mode environment - events are pushed by the agent, not pulled
const env = await getEnvironment(envIdNum);
if (env?.connectionType === 'hawser-edge') {
return new Response(
`event: error\ndata: ${JSON.stringify({ message: 'Edge environments receive events via agent push, not this endpoint' })}\n\n`,
{
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache'
}
}
);
}
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
// Send initial connection event
const sendEvent = (type: string, data: any) => {
const event = `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`;
controller.enqueue(encoder.encode(event));
};
// Send heartbeat to keep connection alive (every 5s to prevent Traefik 10s idle timeout)
const heartbeatInterval = setInterval(() => {
try {
sendEvent('heartbeat', { timestamp: new Date().toISOString() });
} catch {
clearInterval(heartbeatInterval);
}
}, 5000);
sendEvent('connected', { timestamp: new Date().toISOString(), envId: envIdNum });
try {
// Get Docker events stream
const eventStream = await getDockerEvents(
{ type: ['container', 'image', 'volume', 'network'] },
envIdNum
);
if (!eventStream) {
sendEvent('error', { message: 'Failed to connect to Docker events' });
clearInterval(heartbeatInterval);
controller.close();
return;
}
const reader = eventStream.getReader();
const decoder = new TextDecoder();
let buffer = '';
const processEvents = async () => {
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.trim()) {
try {
const event = JSON.parse(line);
// Map Docker event to our format
const mappedEvent = {
type: event.Type,
action: event.Action,
actor: {
id: event.Actor?.ID,
name: event.Actor?.Attributes?.name || event.Actor?.Attributes?.image,
attributes: event.Actor?.Attributes
},
time: event.time,
timeNano: event.timeNano
};
sendEvent('docker', mappedEvent);
} catch {
// Ignore parse errors for partial chunks
}
}
}
}
} catch (error: any) {
console.error('Docker event stream error:', error);
sendEvent('error', { message: error.message });
} finally {
clearInterval(heartbeatInterval);
controller.close();
}
};
processEvents();
} catch (error: any) {
console.error('Failed to connect to Docker events:', error);
sendEvent('error', { message: error.message || 'Failed to connect to Docker' });
clearInterval(heartbeatInterval);
controller.close();
}
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no' // Disable nginx buffering
}
});
};
+88
View File
@@ -0,0 +1,88 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import {
getGitCredentials,
createGitCredential,
type GitAuthType
} from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('git', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const credentials = await getGitCredentials();
// Don't expose sensitive data in list view
const sanitized = credentials.map(cred => ({
id: cred.id,
name: cred.name,
authType: cred.authType,
username: cred.username,
hasPassword: !!cred.password,
hasSshKey: !!cred.sshPrivateKey,
createdAt: cred.createdAt,
updatedAt: cred.updatedAt
}));
return json(sanitized);
} catch (error) {
console.error('Failed to get git credentials:', error);
return json({ error: 'Failed to get git credentials' }, { status: 500 });
}
};
export const POST: RequestHandler = async ({ request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('git', 'create')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const data = await request.json();
if (!data.name || typeof data.name !== 'string') {
return json({ error: 'Name is required' }, { status: 400 });
}
const authType = (data.authType || 'none') as GitAuthType;
if (!['none', 'password', 'ssh'].includes(authType)) {
return json({ error: 'Invalid auth type' }, { status: 400 });
}
if (authType === 'password' && !data.password) {
return json({ error: 'Password is required for password authentication' }, { status: 400 });
}
if (authType === 'ssh' && !data.sshPrivateKey) {
return json({ error: 'SSH private key is required for SSH authentication' }, { status: 400 });
}
const credential = await createGitCredential({
name: data.name,
authType,
username: data.username,
password: data.password,
sshPrivateKey: data.sshPrivateKey,
sshPassphrase: data.sshPassphrase
});
return json({
id: credential.id,
name: credential.name,
authType: credential.authType,
username: credential.username,
hasPassword: !!credential.password,
hasSshKey: !!credential.sshPrivateKey,
createdAt: credential.createdAt,
updatedAt: credential.updatedAt
});
} catch (error: any) {
console.error('Failed to create git credential:', error);
if (error.message?.includes('UNIQUE constraint failed')) {
return json({ error: 'A credential with this name already exists' }, { status: 400 });
}
return json({ error: 'Failed to create git credential' }, { status: 500 });
}
};
@@ -0,0 +1,122 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import {
getGitCredential,
updateGitCredential,
deleteGitCredential,
type GitAuthType
} from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('git', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const id = parseInt(params.id);
if (isNaN(id)) {
return json({ error: 'Invalid credential ID' }, { status: 400 });
}
const credential = await getGitCredential(id);
if (!credential) {
return json({ error: 'Credential not found' }, { status: 404 });
}
// Don't expose sensitive data
return json({
id: credential.id,
name: credential.name,
authType: credential.authType,
username: credential.username,
hasPassword: !!credential.password,
hasSshKey: !!credential.sshPrivateKey,
createdAt: credential.createdAt,
updatedAt: credential.updatedAt
});
} catch (error) {
console.error('Failed to get git credential:', error);
return json({ error: 'Failed to get git credential' }, { status: 500 });
}
};
export const PUT: RequestHandler = async ({ params, request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('git', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const id = parseInt(params.id);
if (isNaN(id)) {
return json({ error: 'Invalid credential ID' }, { status: 400 });
}
const existing = await getGitCredential(id);
if (!existing) {
return json({ error: 'Credential not found' }, { status: 404 });
}
const data = await request.json();
if (data.authType && !['none', 'password', 'ssh'].includes(data.authType)) {
return json({ error: 'Invalid auth type' }, { status: 400 });
}
const credential = await updateGitCredential(id, {
name: data.name,
authType: data.authType as GitAuthType,
username: data.username,
password: data.password,
sshPrivateKey: data.sshPrivateKey,
sshPassphrase: data.sshPassphrase
});
if (!credential) {
return json({ error: 'Failed to update credential' }, { status: 500 });
}
return json({
id: credential.id,
name: credential.name,
authType: credential.authType,
username: credential.username,
hasPassword: !!credential.password,
hasSshKey: !!credential.sshPrivateKey,
createdAt: credential.createdAt,
updatedAt: credential.updatedAt
});
} catch (error: any) {
console.error('Failed to update git credential:', error);
if (error.message?.includes('UNIQUE constraint failed')) {
return json({ error: 'A credential with this name already exists' }, { status: 400 });
}
return json({ error: 'Failed to update git credential' }, { status: 500 });
}
};
export const DELETE: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('git', 'delete')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const id = parseInt(params.id);
if (isNaN(id)) {
return json({ error: 'Invalid credential ID' }, { status: 400 });
}
const deleted = await deleteGitCredential(id);
if (!deleted) {
return json({ error: 'Credential not found' }, { status: 404 });
}
return json({ success: true });
} catch (error) {
console.error('Failed to delete git credential:', error);
return json({ error: 'Failed to delete git credential' }, { status: 500 });
}
};
@@ -0,0 +1,70 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import {
getGitRepositories,
createGitRepository,
getGitCredentials
} from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
export const GET: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('git', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
// Note: envId parameter is kept for backwards compatibility but repositories
// are now global (not tied to environments). Use git stacks for env-specific deployments.
const repositories = await getGitRepositories();
return json(repositories);
} catch (error) {
console.error('Failed to get git repositories:', error);
return json({ error: 'Failed to get git repositories' }, { status: 500 });
}
};
export const POST: RequestHandler = async ({ request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('git', 'create')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const data = await request.json();
if (!data.name || typeof data.name !== 'string') {
return json({ error: 'Name is required' }, { status: 400 });
}
if (!data.url || typeof data.url !== 'string') {
return json({ error: 'Repository URL is required' }, { status: 400 });
}
// Validate credential if provided
if (data.credentialId) {
const credentials = await getGitCredentials();
const credential = credentials.find(c => c.id === data.credentialId);
if (!credential) {
return json({ error: 'Invalid credential ID' }, { status: 400 });
}
}
// Create repository with just the basic fields
// Deployment-specific config (composePath, autoUpdate, webhook) now belongs to git_stacks
const repository = await createGitRepository({
name: data.name,
url: data.url,
branch: data.branch || 'main',
credentialId: data.credentialId || null
});
return json(repository);
} catch (error: any) {
console.error('Failed to create git repository:', error);
if (error.message?.includes('UNIQUE constraint failed')) {
return json({ error: 'A repository with this name already exists' }, { status: 400 });
}
return json({ error: 'Failed to create git repository' }, { status: 500 });
}
};
@@ -0,0 +1,112 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import {
getGitRepository,
updateGitRepository,
deleteGitRepository,
getGitCredentials
} from '$lib/server/db';
import { deleteRepositoryFiles } from '$lib/server/git';
import { authorize } from '$lib/server/authorize';
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('git', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const id = parseInt(params.id);
if (isNaN(id)) {
return json({ error: 'Invalid repository ID' }, { status: 400 });
}
const repository = await getGitRepository(id);
if (!repository) {
return json({ error: 'Repository not found' }, { status: 404 });
}
return json(repository);
} catch (error) {
console.error('Failed to get git repository:', error);
return json({ error: 'Failed to get git repository' }, { status: 500 });
}
};
export const PUT: RequestHandler = async ({ params, request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('git', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const id = parseInt(params.id);
if (isNaN(id)) {
return json({ error: 'Invalid repository ID' }, { status: 400 });
}
const existing = await getGitRepository(id);
if (!existing) {
return json({ error: 'Repository not found' }, { status: 404 });
}
const data = await request.json();
// Validate credential if provided
if (data.credentialId) {
const credentials = await getGitCredentials();
const credential = credentials.find(c => c.id === data.credentialId);
if (!credential) {
return json({ error: 'Invalid credential ID' }, { status: 400 });
}
}
// Update only the basic repository fields
// Deployment-specific config (composePath, autoUpdate, webhook) now belongs to git_stacks
const repository = await updateGitRepository(id, {
name: data.name,
url: data.url,
branch: data.branch,
credentialId: data.credentialId
});
if (!repository) {
return json({ error: 'Failed to update repository' }, { status: 500 });
}
return json(repository);
} catch (error: any) {
console.error('Failed to update git repository:', error);
if (error.message?.includes('UNIQUE constraint failed')) {
return json({ error: 'A repository with this name already exists' }, { status: 400 });
}
return json({ error: 'Failed to update git repository' }, { status: 500 });
}
};
export const DELETE: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('git', 'delete')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const id = parseInt(params.id);
if (isNaN(id)) {
return json({ error: 'Invalid repository ID' }, { status: 400 });
}
// Delete repository files first
deleteRepositoryFiles(id);
const deleted = await deleteGitRepository(id);
if (!deleted) {
return json({ error: 'Repository not found' }, { status: 404 });
}
return json({ success: true });
} catch (error) {
console.error('Failed to delete git repository:', error);
return json({ error: 'Failed to delete git repository' }, { status: 500 });
}
};
@@ -0,0 +1,24 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getGitRepository } from '$lib/server/db';
import { deployFromRepository } from '$lib/server/git';
export const POST: RequestHandler = async ({ params }) => {
try {
const id = parseInt(params.id);
if (isNaN(id)) {
return json({ error: 'Invalid repository ID' }, { status: 400 });
}
const repository = await getGitRepository(id);
if (!repository) {
return json({ error: 'Repository not found' }, { status: 404 });
}
const result = await deployFromRepository(id);
return json(result);
} catch (error: any) {
console.error('Failed to deploy from git repository:', error);
return json({ success: false, error: error.message }, { status: 500 });
}
};
@@ -0,0 +1,45 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getGitRepository } from '$lib/server/db';
import { syncRepository, checkForUpdates } from '$lib/server/git';
export const POST: RequestHandler = async ({ params }) => {
try {
const id = parseInt(params.id);
if (isNaN(id)) {
return json({ error: 'Invalid repository ID' }, { status: 400 });
}
const repository = await getGitRepository(id);
if (!repository) {
return json({ error: 'Repository not found' }, { status: 404 });
}
const result = await syncRepository(id);
return json(result);
} catch (error: any) {
console.error('Failed to sync git repository:', error);
return json({ success: false, error: error.message }, { status: 500 });
}
};
export const GET: RequestHandler = async ({ params }) => {
// Check for updates without syncing
try {
const id = parseInt(params.id);
if (isNaN(id)) {
return json({ error: 'Invalid repository ID' }, { status: 400 });
}
const repository = await getGitRepository(id);
if (!repository) {
return json({ error: 'Repository not found' }, { status: 404 });
}
const result = await checkForUpdates(id);
return json(result);
} catch (error: any) {
console.error('Failed to check for updates:', error);
return json({ hasUpdates: false, error: error.message }, { status: 500 });
}
};
@@ -0,0 +1,24 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getGitRepository } from '$lib/server/db';
import { testRepository } from '$lib/server/git';
export const POST: RequestHandler = async ({ params }) => {
try {
const id = parseInt(params.id);
if (isNaN(id)) {
return json({ error: 'Invalid repository ID' }, { status: 400 });
}
const repository = await getGitRepository(id);
if (!repository) {
return json({ error: 'Repository not found' }, { status: 404 });
}
const result = await testRepository(id);
return json(result);
} catch (error: any) {
console.error('Failed to test git repository:', error);
return json({ success: false, error: error.message }, { status: 500 });
}
};
@@ -0,0 +1,41 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { testRepositoryConfig } from '$lib/server/git';
import { authorize } from '$lib/server/authorize';
/**
* POST /api/git/repositories/test
* Test a git repository configuration before saving.
* Uses stored credentials via credentialId.
*
* Body: {
* url: string; // Repository URL to test
* branch: string; // Branch name to verify
* credentialId?: number; // Optional credential ID from database
* }
*/
export const POST: RequestHandler = async ({ request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('settings', 'manage')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const body = await request.json();
if (!body.url || typeof body.url !== 'string') {
return json({ error: 'Repository URL is required' }, { status: 400 });
}
const result = await testRepositoryConfig({
url: body.url,
branch: body.branch || 'main',
credentialId: body.credentialId ?? null
});
return json(result);
} catch (error) {
console.error('Failed to test repository:', error);
return json({ success: false, error: 'Failed to test repository' }, { status: 500 });
}
};
+144
View File
@@ -0,0 +1,144 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import {
getGitStacks,
createGitStack,
getGitCredentials,
getGitRepository,
createGitRepository,
upsertStackSource
} from '$lib/server/db';
import { deployGitStack } from '$lib/server/git';
import { authorize } from '$lib/server/authorize';
import { registerSchedule } from '$lib/server/scheduler';
import crypto from 'node:crypto';
export const GET: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('stacks', 'view', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const stacks = await getGitStacks(envIdNum);
return json(stacks);
} catch (error) {
console.error('Failed to get git stacks:', error);
return json({ error: 'Failed to get git stacks' }, { status: 500 });
}
};
export const POST: RequestHandler = async ({ request, cookies }) => {
const auth = await authorize(cookies);
try {
const data = await request.json();
// Permission check with environment context
if (auth.authEnabled && !await auth.can('stacks', 'create', data.environmentId || undefined)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
if (!data.stackName || typeof data.stackName !== 'string') {
return json({ error: 'Stack name is required' }, { status: 400 });
}
// Either repositoryId or new repo details (url, branch) must be provided
let repositoryId = data.repositoryId;
if (!repositoryId) {
// Create a new repository if URL is provided
if (!data.url || typeof data.url !== 'string') {
return json({ error: 'Repository URL or existing repository ID is required' }, { status: 400 });
}
// Validate credential if provided
if (data.credentialId) {
const credentials = await getGitCredentials();
const credential = credentials.find(c => c.id === data.credentialId);
if (!credential) {
return json({ error: 'Invalid credential ID' }, { status: 400 });
}
}
// Create the repository first
const repoName = data.repoName || data.stackName;
try {
const repo = await createGitRepository({
name: repoName,
url: data.url,
branch: data.branch || 'main',
credentialId: data.credentialId || null
});
repositoryId = repo.id;
} catch (error: any) {
if (error.message?.includes('UNIQUE constraint failed')) {
return json({ error: 'A repository with this name already exists' }, { status: 400 });
}
throw error;
}
} else {
// Verify repository exists
const repo = await getGitRepository(repositoryId);
if (!repo) {
return json({ error: 'Repository not found' }, { status: 400 });
}
}
// Generate webhook secret if webhook is enabled
let webhookSecret = data.webhookSecret;
if (data.webhookEnabled && !webhookSecret) {
webhookSecret = crypto.randomBytes(32).toString('hex');
}
const gitStack = await createGitStack({
stackName: data.stackName,
environmentId: data.environmentId || null,
repositoryId: repositoryId,
composePath: data.composePath || 'docker-compose.yml',
envFilePath: data.envFilePath || null,
autoUpdate: data.autoUpdate || false,
autoUpdateSchedule: data.autoUpdateSchedule || 'daily',
autoUpdateCron: data.autoUpdateCron || '0 3 * * *',
webhookEnabled: data.webhookEnabled || false,
webhookSecret: webhookSecret
});
// Create stack_sources entry so the stack appears in the list immediately
await upsertStackSource({
stackName: data.stackName,
environmentId: data.environmentId || null,
sourceType: 'git',
gitRepositoryId: repositoryId,
gitStackId: gitStack.id
});
// Register schedule with croner if auto-update is enabled
if (gitStack.autoUpdate && gitStack.autoUpdateCron) {
await registerSchedule(gitStack.id, 'git_stack_sync', gitStack.environmentId);
}
// If deployNow is set, deploy immediately
if (data.deployNow) {
const deployResult = await deployGitStack(gitStack.id);
return json({
...gitStack,
deployResult: deployResult
});
}
return json(gitStack);
} catch (error: any) {
console.error('Failed to create git stack:', error);
if (error.message?.includes('UNIQUE constraint failed')) {
return json({ error: 'A git stack with this name already exists for this environment' }, { status: 400 });
}
return json({ error: 'Failed to create git stack' }, { status: 500 });
}
};
+112
View File
@@ -0,0 +1,112 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getGitStack, updateGitStack, deleteGitStack } from '$lib/server/db';
import { deleteGitStackFiles, deployGitStack } from '$lib/server/git';
import { authorize } from '$lib/server/authorize';
import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler';
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
try {
const id = parseInt(params.id);
const gitStack = await getGitStack(id);
if (!gitStack) {
return json({ error: 'Git stack not found' }, { status: 404 });
}
// Permission check with environment context
if (auth.authEnabled && !await auth.can('stacks', 'view', gitStack.environmentId || undefined)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
return json(gitStack);
} catch (error) {
console.error('Failed to get git stack:', error);
return json({ error: 'Failed to get git stack' }, { status: 500 });
}
};
export const PUT: RequestHandler = async ({ params, request, cookies }) => {
const auth = await authorize(cookies);
try {
const id = parseInt(params.id);
const existing = await getGitStack(id);
if (!existing) {
return json({ error: 'Git stack not found' }, { status: 404 });
}
// Permission check with environment context
if (auth.authEnabled && !await auth.can('stacks', 'edit', existing.environmentId || undefined)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const data = await request.json();
const updated = await updateGitStack(id, {
stackName: data.stackName,
composePath: data.composePath,
envFilePath: data.envFilePath,
autoUpdate: data.autoUpdate,
autoUpdateSchedule: data.autoUpdateSchedule,
autoUpdateCron: data.autoUpdateCron,
webhookEnabled: data.webhookEnabled,
webhookSecret: data.webhookSecret
});
// Register or unregister schedule with croner
if (updated.autoUpdate && updated.autoUpdateCron) {
await registerSchedule(id, 'git_stack_sync', updated.environmentId);
} else {
unregisterSchedule(id, 'git_stack_sync');
}
// If deployNow is set, deploy after saving
if (data.deployNow) {
const deployResult = await deployGitStack(id);
return json({
...updated,
deployResult
});
}
return json(updated);
} catch (error: any) {
console.error('Failed to update git stack:', error);
if (error.message?.includes('UNIQUE constraint failed')) {
return json({ error: 'A git stack with this name already exists for this environment' }, { status: 400 });
}
return json({ error: 'Failed to update git stack' }, { status: 500 });
}
};
export const DELETE: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
try {
const id = parseInt(params.id);
const existing = await getGitStack(id);
if (!existing) {
return json({ error: 'Git stack not found' }, { status: 404 });
}
// Permission check with environment context
if (auth.authEnabled && !await auth.can('stacks', 'remove', existing.environmentId || undefined)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
// Unregister schedule from croner
unregisterSchedule(id, 'git_stack_sync');
// Delete git files first
deleteGitStackFiles(id);
// Delete from database
await deleteGitStack(id);
return json({ success: true });
} catch (error) {
console.error('Failed to delete git stack:', error);
return json({ error: 'Failed to delete git stack' }, { status: 500 });
}
};
@@ -0,0 +1,54 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getGitStack } from '$lib/server/db';
import { deployGitStackWithProgress } from '$lib/server/git';
import { authorize } from '$lib/server/authorize';
export const POST: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
const id = parseInt(params.id);
const gitStack = await getGitStack(id);
if (!gitStack) {
return new Response(JSON.stringify({ error: 'Git stack not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' }
});
}
// Permission check with environment context
if (auth.authEnabled && !await auth.can('stacks', 'start', gitStack.environmentId || undefined)) {
return new Response(JSON.stringify({ error: 'Permission denied' }), {
status: 403,
headers: { 'Content-Type': 'application/json' }
});
}
// Create a readable stream for SSE
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
const sendEvent = (data: any) => {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
};
try {
await deployGitStackWithProgress(id, sendEvent);
} catch (error: any) {
sendEvent({ status: 'error', error: error.message || 'Unknown error' });
} finally {
controller.close();
}
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
}
});
};
@@ -0,0 +1,28 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getGitStack } from '$lib/server/db';
import { deployGitStack } from '$lib/server/git';
import { authorize } from '$lib/server/authorize';
export const POST: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
try {
const id = parseInt(params.id);
const gitStack = await getGitStack(id);
if (!gitStack) {
return json({ error: 'Git stack not found' }, { status: 404 });
}
// Permission check with environment context
if (auth.authEnabled && !await auth.can('stacks', 'start', gitStack.environmentId || undefined)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const result = await deployGitStack(id);
return json(result);
} catch (error) {
console.error('Failed to deploy git stack:', error);
return json({ error: 'Failed to deploy git stack' }, { status: 500 });
}
};
@@ -0,0 +1,75 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getGitStack } from '$lib/server/db';
import { listGitStackEnvFiles, readGitStackEnvFile } from '$lib/server/git';
import { authorize } from '$lib/server/authorize';
/**
* GET /api/git/stacks/[id]/env-files
* List all .env files in the git stack's repository.
* Returns: { files: string[] }
*/
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
try {
const id = parseInt(params.id);
const gitStack = await getGitStack(id);
if (!gitStack) {
return json({ error: 'Git stack not found' }, { status: 404 });
}
// Permission check with environment context
if (auth.authEnabled && !await auth.can('stacks', 'view', gitStack.environmentId || undefined)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const result = await listGitStackEnvFiles(id);
if (result.error) {
return json({ files: [], error: result.error }, { status: 400 });
}
return json({ files: result.files });
} catch (error) {
console.error('Failed to list env files:', error);
return json({ error: 'Failed to list env files' }, { status: 500 });
}
};
/**
* POST /api/git/stacks/[id]/env-files
* Read and parse a specific .env file from the git stack's repository.
* Body: { path: string }
* Returns: { vars: Record<string, string> }
*/
export const POST: RequestHandler = async ({ params, cookies, request }) => {
const auth = await authorize(cookies);
try {
const id = parseInt(params.id);
const gitStack = await getGitStack(id);
if (!gitStack) {
return json({ error: 'Git stack not found' }, { status: 404 });
}
// Permission check with environment context
if (auth.authEnabled && !await auth.can('stacks', 'view', gitStack.environmentId || undefined)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const body = await request.json();
if (!body.path || typeof body.path !== 'string') {
return json({ error: 'File path is required' }, { status: 400 });
}
const result = await readGitStackEnvFile(id, body.path);
if (result.error) {
return json({ vars: {}, error: result.error }, { status: 400 });
}
return json({ vars: result.vars });
} catch (error) {
console.error('Failed to read env file:', error);
return json({ error: 'Failed to read env file' }, { status: 500 });
}
};
@@ -0,0 +1,28 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getGitStack } from '$lib/server/db';
import { syncGitStack } from '$lib/server/git';
import { authorize } from '$lib/server/authorize';
export const POST: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
try {
const id = parseInt(params.id);
const gitStack = await getGitStack(id);
if (!gitStack) {
return json({ error: 'Git stack not found' }, { status: 404 });
}
// Permission check with environment context
if (auth.authEnabled && !await auth.can('stacks', 'edit', gitStack.environmentId || undefined)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const result = await syncGitStack(id);
return json(result);
} catch (error) {
console.error('Failed to sync git stack:', error);
return json({ error: 'Failed to sync git stack' }, { status: 500 });
}
};
@@ -0,0 +1,28 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getGitStack } from '$lib/server/db';
import { testGitStack } from '$lib/server/git';
import { authorize } from '$lib/server/authorize';
export const POST: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
try {
const id = parseInt(params.id);
const gitStack = await getGitStack(id);
if (!gitStack) {
return json({ error: 'Git stack not found' }, { status: 404 });
}
// Permission check with environment context
if (auth.authEnabled && !await auth.can('stacks', 'view', gitStack.environmentId || undefined)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const result = await testGitStack(id);
return json(result);
} catch (error) {
console.error('Failed to test git stack:', error);
return json({ error: 'Failed to test git stack' }, { status: 500 });
}
};
@@ -0,0 +1,97 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getGitStack } from '$lib/server/db';
import { deployGitStack } from '$lib/server/git';
import crypto from 'node:crypto';
function verifySignature(payload: string, signature: string | null, secret: string): boolean {
if (!signature) return false;
// Support both GitHub and GitLab webhook signatures
// GitHub: sha256=<hash>
// GitLab: just the token value in X-Gitlab-Token header
if (signature.startsWith('sha256=')) {
const expectedSignature = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}
// GitLab uses X-Gitlab-Token which should match exactly
return signature === secret;
}
export const POST: RequestHandler = async ({ params, request }) => {
try {
const id = parseInt(params.id);
if (isNaN(id)) {
return json({ error: 'Invalid stack ID' }, { status: 400 });
}
const gitStack = await getGitStack(id);
if (!gitStack) {
return json({ error: 'Git stack not found' }, { status: 404 });
}
if (!gitStack.webhookEnabled) {
return json({ error: 'Webhook is not enabled for this stack' }, { status: 403 });
}
// Verify webhook secret if set
if (gitStack.webhookSecret) {
const payload = await request.text();
const githubSignature = request.headers.get('x-hub-signature-256');
const gitlabToken = request.headers.get('x-gitlab-token');
const signature = githubSignature || gitlabToken;
if (!verifySignature(payload, signature, gitStack.webhookSecret)) {
return json({ error: 'Invalid webhook signature' }, { status: 401 });
}
}
// Deploy the git stack (syncs and deploys only if there are changes)
const result = await deployGitStack(id, { force: false });
return json(result);
} catch (error: any) {
console.error('Webhook error:', error);
return json({ success: false, error: error.message }, { status: 500 });
}
};
// Also support GET for simple polling/manual triggers
export const GET: RequestHandler = async ({ params, url }) => {
try {
const id = parseInt(params.id);
if (isNaN(id)) {
return json({ error: 'Invalid stack ID' }, { status: 400 });
}
const gitStack = await getGitStack(id);
if (!gitStack) {
return json({ error: 'Git stack not found' }, { status: 404 });
}
if (!gitStack.webhookEnabled) {
return json({ error: 'Webhook is not enabled for this stack' }, { status: 403 });
}
// Verify secret via query parameter for GET requests
const secret = url.searchParams.get('secret');
if (gitStack.webhookSecret && secret !== gitStack.webhookSecret) {
return json({ error: 'Invalid webhook secret' }, { status: 401 });
}
// Deploy the git stack (syncs and deploys only if there are changes)
const result = await deployGitStack(id, { force: false });
return json(result);
} catch (error: any) {
console.error('Webhook GET error:', error);
return json({ success: false, error: error.message }, { status: 500 });
}
};
+103
View File
@@ -0,0 +1,103 @@
import { json, text } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getGitRepository } from '$lib/server/db';
import { deployFromRepository } from '$lib/server/git';
import crypto from 'node:crypto';
function verifySignature(payload: string, signature: string | null, secret: string): boolean {
if (!signature) return false;
// Support both GitHub and GitLab webhook signatures
// GitHub: sha256=<hash>
// GitLab: just the token value in X-Gitlab-Token header
if (signature.startsWith('sha256=')) {
const expectedSignature = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}
// GitLab uses X-Gitlab-Token which should match exactly
return signature === secret;
}
export const POST: RequestHandler = async ({ params, request }) => {
try {
const id = parseInt(params.id);
if (isNaN(id)) {
return json({ error: 'Invalid repository ID' }, { status: 400 });
}
const repository = await getGitRepository(id);
if (!repository) {
return json({ error: 'Repository not found' }, { status: 404 });
}
if (!repository.webhookEnabled) {
return json({ error: 'Webhook is not enabled for this repository' }, { status: 403 });
}
// Verify webhook secret if set
if (repository.webhookSecret) {
const payload = await request.text();
const githubSignature = request.headers.get('x-hub-signature-256');
const gitlabToken = request.headers.get('x-gitlab-token');
const signature = githubSignature || gitlabToken;
if (!verifySignature(payload, signature, repository.webhookSecret)) {
return json({ error: 'Invalid webhook signature' }, { status: 401 });
}
}
// Optionally check which branch was pushed (for GitHub)
// const body = await request.json();
// if (body.ref && body.ref !== `refs/heads/${repository.branch}`) {
// return json({ message: 'Push was not to tracked branch, skipping' });
// }
// Deploy from repository
const result = await deployFromRepository(id);
return json(result);
} catch (error: any) {
console.error('Webhook error:', error);
return json({ success: false, error: error.message }, { status: 500 });
}
};
// Also support GET for simple polling/manual triggers
export const GET: RequestHandler = async ({ params, url }) => {
try {
const id = parseInt(params.id);
if (isNaN(id)) {
return json({ error: 'Invalid repository ID' }, { status: 400 });
}
const repository = await getGitRepository(id);
if (!repository) {
return json({ error: 'Repository not found' }, { status: 404 });
}
if (!repository.webhookEnabled) {
return json({ error: 'Webhook is not enabled for this repository' }, { status: 403 });
}
// Verify secret via query parameter for GET requests
const secret = url.searchParams.get('secret');
if (repository.webhookSecret && secret !== repository.webhookSecret) {
return json({ error: 'Invalid webhook secret' }, { status: 401 });
}
// Deploy from repository
const result = await deployFromRepository(id);
return json(result);
} catch (error: any) {
console.error('Webhook GET error:', error);
return json({ success: false, error: error.message }, { status: 500 });
}
};
+61
View File
@@ -0,0 +1,61 @@
/**
* Hawser Edge WebSocket Connect Endpoint
*
* This endpoint handles WebSocket connections from Hawser agents running in Edge mode.
* In development: WebSocket is handled by Bun.serve in vite.config.ts on port 5174
* In production: WebSocket is handled by the server wrapper in server.ts
*
* The HTTP GET endpoint returns connection info for clients.
*/
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { isEdgeConnected, getAllEdgeConnections } from '$lib/server/hawser';
/**
* GET /api/hawser/connect
* Returns status of the Hawser Edge connection endpoint
* This is used for health checks and debugging
*/
export const GET: RequestHandler = async () => {
const connections = getAllEdgeConnections();
const connectionList = Array.from(connections.entries()).map(([envId, conn]) => ({
environmentId: envId,
agentId: conn.agentId,
agentName: conn.agentName,
agentVersion: conn.agentVersion,
dockerVersion: conn.dockerVersion,
hostname: conn.hostname,
capabilities: conn.capabilities,
connectedAt: conn.connectedAt.toISOString(),
lastHeartbeat: conn.lastHeartbeat.toISOString()
}));
return json({
status: 'ready',
message: 'Hawser Edge WebSocket endpoint. Connect via WebSocket.',
protocol: 'wss://<host>/api/hawser/connect',
activeConnections: connectionList.length,
connections: connectionList
});
};
/**
* POST /api/hawser/connect
* This is a fallback for non-WebSocket clients.
* Returns instructions for connecting via WebSocket.
*/
export const POST: RequestHandler = async () => {
return json(
{
error: 'WebSocket required',
message: 'This endpoint requires a WebSocket connection. Use the ws:// or wss:// protocol.',
instructions: [
'1. Generate a token in Settings > Environments > [Environment] > Hawser',
'2. Configure your Hawser agent with DOCKHAND_SERVER_URL and TOKEN',
'3. The agent will connect automatically'
]
},
{ status: 426 }
); // 426 Upgrade Required
};
+122
View File
@@ -0,0 +1,122 @@
/**
* Hawser Token Management API
*
* Handles CRUD operations for Hawser agent tokens.
*/
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { db, hawserTokens, eq, desc } from '$lib/server/db/drizzle';
import { generateHawserToken, revokeHawserToken } from '$lib/server/hawser';
/**
* GET /api/hawser/tokens
* List all Hawser tokens (without revealing full token values)
*/
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !auth.isAuthenticated) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
if (auth.authEnabled && !auth.isAdmin) {
return json({ error: 'Admin access required' }, { status: 403 });
}
try {
const tokens = await db
.select({
id: hawserTokens.id,
tokenPrefix: hawserTokens.tokenPrefix,
name: hawserTokens.name,
environmentId: hawserTokens.environmentId,
isActive: hawserTokens.isActive,
lastUsed: hawserTokens.lastUsed,
createdAt: hawserTokens.createdAt,
expiresAt: hawserTokens.expiresAt
})
.from(hawserTokens)
.orderBy(desc(hawserTokens.createdAt));
return json(tokens);
} catch (error) {
console.error('Error fetching Hawser tokens:', error);
return json({ error: 'Failed to fetch tokens' }, { status: 500 });
}
};
/**
* POST /api/hawser/tokens
* Generate a new Hawser token
*
* Body: { name: string, environmentId: number, expiresAt?: string }
* Returns: { token: string, tokenId: number } - token is only shown ONCE
*/
export const POST: RequestHandler = async ({ request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !auth.isAuthenticated) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
if (auth.authEnabled && !auth.isAdmin) {
return json({ error: 'Admin access required' }, { status: 403 });
}
try {
const body = await request.json();
const { name, environmentId, expiresAt, rawToken } = body;
if (!name || typeof name !== 'string') {
return json({ error: 'Token name is required' }, { status: 400 });
}
if (!environmentId || typeof environmentId !== 'number') {
return json({ error: 'Environment ID is required' }, { status: 400 });
}
const result = await generateHawserToken(name, environmentId, expiresAt, rawToken);
return json({
token: result.token,
tokenId: result.tokenId,
message: 'Token generated successfully. Save this token - it will not be shown again.'
});
} catch (error) {
console.error('Error generating Hawser token:', error);
return json({ error: 'Failed to generate token' }, { status: 500 });
}
};
/**
* DELETE /api/hawser/tokens
* Delete (revoke) a token by ID
*
* Query: ?id=<token_id>
*/
export const DELETE: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !auth.isAuthenticated) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
if (auth.authEnabled && !auth.isAdmin) {
return json({ error: 'Admin access required' }, { status: 403 });
}
const tokenId = url.searchParams.get('id');
if (!tokenId) {
return json({ error: 'Token ID is required' }, { status: 400 });
}
try {
await revokeHawserToken(parseInt(tokenId, 10));
return json({ success: true, message: 'Token revoked' });
} catch (error) {
console.error('Error revoking Hawser token:', error);
return json({ error: 'Failed to revoke token' }, { status: 500 });
}
};
+6
View File
@@ -0,0 +1,6 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async () => {
return json({ status: 'ok', timestamp: new Date().toISOString() });
};
+58
View File
@@ -0,0 +1,58 @@
/**
* Database Health Check Endpoint
*
* Returns detailed information about the database schema state,
* including migration status, table existence, and connection info.
*
* GET /api/health/database
*
* Response:
* {
* healthy: boolean,
* database: 'sqlite' | 'postgresql',
* connection: string,
* migrationsTable: boolean,
* appliedMigrations: number,
* pendingMigrations: number,
* schemaVersion: string | null,
* tables: {
* expected: number,
* found: number,
* missing: string[]
* },
* timestamp: string
* }
*/
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { checkSchemaHealth } from '$lib/server/db/drizzle';
export const GET: RequestHandler = async () => {
try {
const health = await checkSchemaHealth();
return json(health, {
status: health.healthy ? 200 : 503,
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate'
}
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
return json(
{
healthy: false,
error: message,
timestamp: new Date().toISOString()
},
{
status: 500,
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate'
}
}
);
}
};
+158
View File
@@ -0,0 +1,158 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getDockerInfo, getHawserInfo } from '$lib/server/docker';
import { getEnvironment } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { getEdgeConnectionInfo } from '$lib/server/hawser';
import os from 'node:os';
export interface HostInfo {
hostname: string;
ipAddress: string;
platform: string;
arch: string;
cpus: number;
totalMemory: number;
freeMemory: number;
uptime: number;
dockerVersion: string;
dockerContainers: number;
dockerContainersRunning: number;
dockerImages: number;
environment: {
id: number;
name: string;
icon?: string;
socketPath?: string;
connectionType?: string;
hawserVersion?: string;
highlightChanges?: boolean;
};
}
function getLocalIpAddress(): string {
const interfaces = os.networkInterfaces();
for (const name of Object.keys(interfaces)) {
const netInterface = interfaces[name];
if (!netInterface) continue;
for (const net of netInterface) {
// Skip internal and non-IPv4 addresses
if (!net.internal && net.family === 'IPv4') {
return net.address;
}
}
}
return '127.0.0.1';
}
export const GET: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
// Check basic environment view permission
if (auth.authEnabled && !await auth.can('environments', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
// Get environment ID from query param, or use default
const envIdParam = url.searchParams.get('env');
let env;
if (envIdParam) {
const envId = parseInt(envIdParam);
// Check if user can access this specific environment
if (auth.authEnabled && auth.isEnterprise && !await auth.canAccessEnvironment(envId)) {
return json({ error: 'Access denied to this environment' }, { status: 403 });
}
env = await getEnvironment(envId);
}
if (!env) {
// No environment specified - return basic local info
return json({
hostname: os.hostname(),
ipAddress: getLocalIpAddress(),
platform: os.platform(),
arch: os.arch(),
cpus: os.cpus().length,
totalMemory: os.totalmem(),
freeMemory: os.freemem(),
uptime: os.uptime(),
dockerVersion: null,
dockerContainers: 0,
dockerContainersRunning: 0,
dockerImages: 0,
environment: null
});
}
// Determine if this is a truly local connection (socket without remote host)
const isSocketType = env.connectionType === 'socket' || !env.connectionType;
const isLocalConnection = isSocketType && (!env.host || env.host === 'localhost' || env.host === '127.0.0.1');
// Fetch Docker info and Hawser info in parallel for hawser-standard mode
let dockerInfo: any;
let uptime = 0;
let hawserVersion: string | undefined;
if (env.connectionType === 'hawser-standard') {
// Parallel fetch for hawser-standard
const [dockerResult, hawserInfo] = await Promise.all([
getDockerInfo(env.id),
getHawserInfo(env.id)
]);
dockerInfo = dockerResult;
if (hawserInfo?.uptime) {
uptime = hawserInfo.uptime;
}
if (hawserInfo?.hawserVersion) {
hawserVersion = hawserInfo.hawserVersion;
}
} else {
// Sequential for other connection types
dockerInfo = await getDockerInfo(env.id);
if (isLocalConnection) {
uptime = os.uptime();
} else if (env.connectionType === 'hawser-edge') {
// For Hawser edge mode, get from edge connection metrics (sync lookup)
const edgeConn = getEdgeConnectionInfo(env.id);
if (edgeConn?.lastMetrics?.uptime) {
uptime = edgeConn.lastMetrics.uptime;
}
}
// For 'direct' connections without Hawser, uptime remains 0 (not available)
}
const hostInfo: HostInfo = {
// For local connections, show local system info; for remote, show Docker host info
hostname: isLocalConnection ? os.hostname() : (dockerInfo.Name || env.host || 'unknown'),
ipAddress: isLocalConnection ? getLocalIpAddress() : (env.host || 'unknown'),
platform: isLocalConnection ? os.platform() : (dockerInfo.OperatingSystem || 'unknown'),
arch: isLocalConnection ? os.arch() : (dockerInfo.Architecture || 'unknown'),
cpus: isLocalConnection ? os.cpus().length : (dockerInfo.NCPU || 0),
totalMemory: isLocalConnection ? os.totalmem() : (dockerInfo.MemTotal || 0),
freeMemory: isLocalConnection ? os.freemem() : 0, // Not available from Docker API
uptime,
dockerVersion: dockerInfo.ServerVersion || 'unknown',
dockerContainers: dockerInfo.Containers || 0,
dockerContainersRunning: dockerInfo.ContainersRunning || 0,
dockerImages: dockerInfo.Images || 0,
environment: {
id: env.id,
name: env.name,
icon: env.icon,
socketPath: env.socketPath,
connectionType: env.connectionType || 'socket',
// For standard mode, use live-fetched version; for edge mode, use stored version
hawserVersion: hawserVersion || env.hawserVersion,
highlightChanges: env.highlightChanges
}
};
return json(hostInfo);
} catch (error) {
console.error('Failed to get host info:', error);
return json({ error: 'Failed to get host info' }, { status: 500 });
}
};
+39
View File
@@ -0,0 +1,39 @@
import { json } from '@sveltejs/kit';
import { listImages, EnvironmentNotFoundError } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { hasEnvironments } from '$lib/server/db';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('images', 'view', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
// Environment access check (enterprise only)
if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) {
return json({ error: 'Access denied to this environment' }, { status: 403 });
}
// Early return if no environment specified
if (!envIdNum) {
return json([]);
}
try {
const images = await listImages(envIdNum);
return json(images);
} catch (error) {
if (error instanceof EnvironmentNotFoundError) {
return json({ error: 'Environment not found' }, { status: 404 });
}
console.error('Error listing images:', error);
// Return empty array instead of error to allow UI to load
return json([]);
}
};
+61
View File
@@ -0,0 +1,61 @@
import { json } from '@sveltejs/kit';
import { removeImage, inspectImage } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditImage } from '$lib/server/audit';
import type { RequestHandler } from './$types';
export const DELETE: RequestHandler = async (event) => {
const { params, url, cookies } = event;
const auth = await authorize(cookies);
const force = url.searchParams.get('force') === 'true';
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('images', 'remove', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
// Environment access check (enterprise only)
if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) {
return json({ error: 'Access denied to this environment' }, { status: 403 });
}
try {
console.log('Delete image request - params.id:', params.id, 'force:', force, 'envId:', envIdNum);
// Get image name for audit before deleting
let imageName = params.id;
try {
const imageInfo = await inspectImage(params.id, envIdNum);
imageName = imageInfo.RepoTags?.[0] || params.id;
} catch (e) {
console.log('Could not inspect image:', e);
// Use ID if can't get name
}
await removeImage(params.id, force, envIdNum);
// Audit log
await auditImage(event, 'delete', params.id, imageName, envIdNum, { force });
return json({ success: true });
} catch (error: any) {
console.error('Error removing image:', error.message, 'statusCode:', error.statusCode, 'json:', error.json);
// Handle specific Docker errors
if (error.statusCode === 409) {
const message = error.json?.message || error.message || '';
if (message.includes('being used by running container')) {
return json({ error: 'Cannot delete image: it is being used by a running container. Stop the container first.' }, { status: 409 });
}
if (message.includes('has dependent child images')) {
return json({ error: 'Cannot delete image: it has dependent child images. Delete those first or use force delete.' }, { status: 409 });
}
return json({ error: message || 'Image is in use and cannot be deleted' }, { status: 409 });
}
return json({ error: 'Failed to remove image' }, { status: 500 });
}
};
@@ -0,0 +1,79 @@
import { json } from '@sveltejs/kit';
import { exportImage, inspectImage } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { createGzip } from 'zlib';
import { Readable } from 'stream';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
const compress = url.searchParams.get('compress') === 'true';
// Permission check with environment context
if (auth.authEnabled && !await auth.can('images', 'inspect', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
// Get image info for filename
let imageName = params.id;
try {
const imageInfo = await inspectImage(params.id, envIdNum);
if (imageInfo.RepoTags?.[0]) {
// Use first tag, replace : and / with _ for filename safety
imageName = imageInfo.RepoTags[0].replace(/[:/]/g, '_');
} else {
// Use short ID
imageName = params.id.replace('sha256:', '').slice(0, 12);
}
} catch {
// Use ID as fallback
imageName = params.id.replace('sha256:', '').slice(0, 12);
}
// Get the tar stream from Docker
const dockerResponse = await exportImage(params.id, envIdNum);
if (!dockerResponse.body) {
return json({ error: 'No response body from Docker' }, { status: 500 });
}
const extension = compress ? 'tar.gz' : 'tar';
const filename = `${imageName}.${extension}`;
const contentType = compress ? 'application/gzip' : 'application/x-tar';
if (compress) {
// Create a gzip stream and pipe the tar through it
const gzip = createGzip();
const nodeStream = Readable.fromWeb(dockerResponse.body as any);
const compressedStream = nodeStream.pipe(gzip);
// Convert back to web stream
const webStream = Readable.toWeb(compressedStream) as ReadableStream;
return new Response(webStream, {
headers: {
'Content-Type': contentType,
'Content-Disposition': `attachment; filename="${filename}"`,
'Cache-Control': 'no-cache'
}
});
} else {
// Return the tar stream directly
return new Response(dockerResponse.body, {
headers: {
'Content-Type': contentType,
'Content-Disposition': `attachment; filename="${filename}"`,
'Cache-Control': 'no-cache'
}
});
}
} catch (error: any) {
console.error('Error exporting image:', error);
return json({ error: error.message || 'Failed to export image' }, { status: 500 });
}
};
@@ -0,0 +1,24 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getImageHistory } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('images', 'inspect', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const history = await getImageHistory(params.id, envIdNum);
return json(history);
} catch (error) {
console.error('Failed to get image history:', error);
return json({ error: 'Failed to get image history' }, { status: 500 });
}
};
+28
View File
@@ -0,0 +1,28 @@
import { json } from '@sveltejs/kit';
import { tagImage } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ params, request, url, cookies }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context (Tagging is similar to building/modifying)
if (auth.authEnabled && !await auth.can('images', 'build', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const { repo, tag } = await request.json();
if (!repo || typeof repo !== 'string') {
return json({ error: 'Repository name is required' }, { status: 400 });
}
await tagImage(params.id, repo, tag || 'latest', envIdNum);
return json({ success: true });
} catch (error) {
console.error('Error tagging image:', error);
return json({ error: 'Failed to tag image' }, { status: 500 });
}
};
+265
View File
@@ -0,0 +1,265 @@
import { json } from '@sveltejs/kit';
import { pullImage } from '$lib/server/docker';
import type { RequestHandler } from './$types';
import { getScannerSettings, scanImage } from '$lib/server/scanner';
import { saveVulnerabilityScan, getEnvironment } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { auditImage } from '$lib/server/audit';
import { sendEdgeStreamRequest, isEdgeConnected } from '$lib/server/hawser';
/**
* Check if environment is edge mode
*/
async function isEdgeMode(envId?: number): Promise<{ isEdge: boolean; environmentId?: number }> {
if (!envId) {
return { isEdge: false };
}
const env = await getEnvironment(envId);
if (env?.connectionType === 'hawser-edge') {
return { isEdge: true, environmentId: envId };
}
return { isEdge: false };
}
/**
* Build image pull URL with proper tag handling
*/
function buildPullUrl(imageName: string): string {
let fromImage = imageName;
let tag = 'latest';
if (imageName.includes('@')) {
fromImage = imageName;
tag = '';
} else if (imageName.includes(':')) {
const lastColonIndex = imageName.lastIndexOf(':');
const potentialTag = imageName.substring(lastColonIndex + 1);
if (!potentialTag.includes('/')) {
fromImage = imageName.substring(0, lastColonIndex);
tag = potentialTag;
}
}
return tag
? `/images/create?fromImage=${encodeURIComponent(fromImage)}&tag=${encodeURIComponent(tag)}`
: `/images/create?fromImage=${encodeURIComponent(fromImage)}`;
}
export const POST: RequestHandler = async (event) => {
const { request, url, cookies } = event;
const auth = await authorize(cookies);
const envIdParam = url.searchParams.get('env');
const envId = envIdParam ? parseInt(envIdParam) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('images', 'pull', envId)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
// Environment access check (enterprise only)
if (envId && auth.isEnterprise && !await auth.canAccessEnvironment(envId)) {
return json({ error: 'Access denied to this environment' }, { status: 403 });
}
const { image, scanAfterPull } = await request.json();
// If scanAfterPull is explicitly false, skip scan-on-pull (caller will handle scanning)
const skipScanOnPull = scanAfterPull === false;
// Audit log the pull attempt
await auditImage(event, 'pull', image, image, envId);
// Check if this is an edge environment
const edgeCheck = await isEdgeMode(envId);
const encoder = new TextEncoder();
let controllerClosed = false;
let controller: ReadableStreamDefaultController<Uint8Array>;
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
let cancelEdgeStream: (() => void) | null = null;
const safeEnqueue = (data: string) => {
if (!controllerClosed) {
try {
controller.enqueue(encoder.encode(data));
} catch {
controllerClosed = true;
}
}
};
const cleanup = () => {
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
}
if (cancelEdgeStream) {
cancelEdgeStream();
cancelEdgeStream = null;
}
controllerClosed = true;
};
/**
* Handle scan-on-pull after image is pulled
*/
const handleScanOnPull = async () => {
// Skip if caller explicitly requested no scan (e.g., CreateContainerModal handles scanning separately)
if (skipScanOnPull) return;
const { scanner } = await getScannerSettings(envId);
// Scan if scanning is enabled (scanner !== 'none')
if (scanner !== 'none') {
safeEnqueue(`data: ${JSON.stringify({ status: 'scanning', message: 'Starting vulnerability scan...' })}\n\n`);
try {
const results = await scanImage(image, envId, (progress) => {
safeEnqueue(`data: ${JSON.stringify({ status: 'scan-progress', ...progress })}\n\n`);
});
for (const result of results) {
await saveVulnerabilityScan({
environmentId: envId ?? null,
imageId: result.imageId,
imageName: result.imageName,
scanner: result.scanner,
scannedAt: result.scannedAt,
scanDuration: result.scanDuration,
criticalCount: result.summary.critical,
highCount: result.summary.high,
mediumCount: result.summary.medium,
lowCount: result.summary.low,
negligibleCount: result.summary.negligible,
unknownCount: result.summary.unknown,
vulnerabilities: result.vulnerabilities,
error: result.error ?? null
});
}
const totalVulns = results.reduce((sum, r) => sum + r.vulnerabilities.length, 0);
safeEnqueue(`data: ${JSON.stringify({
status: 'scan-complete',
message: `Scan complete - found ${totalVulns} vulnerabilities`,
results
})}\n\n`);
} catch (scanError) {
console.error('Scan-on-pull failed:', scanError);
safeEnqueue(`data: ${JSON.stringify({
status: 'scan-error',
error: scanError instanceof Error ? scanError.message : String(scanError)
})}\n\n`);
}
}
};
const stream = new ReadableStream({
async start(ctrl) {
controller = ctrl;
// Start heartbeat to keep connection alive through Traefik (10s idle timeout)
heartbeatInterval = setInterval(() => {
safeEnqueue(`: keepalive\n\n`);
}, 5000);
console.log(`Starting pull for image: ${image}${edgeCheck.isEdge ? ' (edge mode)' : ''}`);
// Handle edge mode with streaming
if (edgeCheck.isEdge && edgeCheck.environmentId) {
if (!isEdgeConnected(edgeCheck.environmentId)) {
safeEnqueue(`data: ${JSON.stringify({ status: 'error', error: 'Edge agent not connected' })}\n\n`);
cleanup();
controller.close();
return;
}
const pullUrl = buildPullUrl(image);
const { cancel } = sendEdgeStreamRequest(
edgeCheck.environmentId,
'POST',
pullUrl,
{
onData: (data: string) => {
// Data is base64 encoded JSON lines from Docker
try {
const decoded = Buffer.from(data, 'base64').toString('utf-8');
// Docker sends newline-delimited JSON
const lines = decoded.split('\n').filter(line => line.trim());
for (const line of lines) {
try {
const progress = JSON.parse(line);
safeEnqueue(`data: ${JSON.stringify(progress)}\n\n`);
} catch {
// Ignore parse errors for partial lines
}
}
} catch {
// If not base64, try as-is
try {
const progress = JSON.parse(data);
safeEnqueue(`data: ${JSON.stringify(progress)}\n\n`);
} catch {
// Ignore
}
}
},
onEnd: async () => {
safeEnqueue(`data: ${JSON.stringify({ status: 'complete' })}\n\n`);
// Handle scan-on-pull
await handleScanOnPull();
cleanup();
controller.close();
},
onError: (error: string) => {
console.error('Edge pull error:', error);
safeEnqueue(`data: ${JSON.stringify({ status: 'error', error })}\n\n`);
cleanup();
controller.close();
}
}
);
cancelEdgeStream = cancel;
} else {
// Non-edge mode: use existing pullImage function
try {
await pullImage(image, (progress) => {
const data = JSON.stringify(progress) + '\n';
safeEnqueue(`data: ${data}\n\n`);
}, envId);
safeEnqueue(`data: ${JSON.stringify({ status: 'complete' })}\n\n`);
// Handle scan-on-pull
await handleScanOnPull();
cleanup();
controller.close();
} catch (error) {
console.error('Error pulling image:', error);
safeEnqueue(`data: ${JSON.stringify({
status: 'error',
error: String(error)
})}\n\n`);
cleanup();
controller.close();
}
}
},
cancel() {
cleanup();
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no'
}
});
};
+303
View File
@@ -0,0 +1,303 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { inspectImage, tagImage, pushImage } from '$lib/server/docker';
import { getRegistry, getEnvironment } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { auditImage } from '$lib/server/audit';
import { sendEdgeStreamRequest, isEdgeConnected } from '$lib/server/hawser';
/**
* Check if environment is edge mode
*/
async function isEdgeMode(envId?: number): Promise<{ isEdge: boolean; environmentId?: number }> {
if (!envId) {
return { isEdge: false };
}
const env = await getEnvironment(envId);
if (env?.connectionType === 'hawser-edge') {
return { isEdge: true, environmentId: envId };
}
return { isEdge: false };
}
export const POST: RequestHandler = async (event) => {
const { request, url, cookies } = event;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('images', 'push', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const { imageId, imageName, registryId, newTag } = await request.json();
if (!imageId || !registryId) {
return json({ error: 'Image ID and registry ID are required' }, { status: 400 });
}
const registry = await getRegistry(registryId);
if (!registry) {
return json({ error: 'Registry not found' }, { status: 404 });
}
// Get the image info
const imageInfo = await inspectImage(imageId, envIdNum) as any;
// Determine the source tag to use
let sourceTag = imageName;
if (!sourceTag && imageInfo.RepoTags && imageInfo.RepoTags.length > 0) {
sourceTag = imageInfo.RepoTags[0];
}
if (!sourceTag || sourceTag === '<none>:<none>') {
return json({ error: 'Image has no tag. Please provide a tag name.' }, { status: 400 });
}
// Extract just the image name (without registry prefix if any)
let baseImageName = sourceTag;
// Remove any existing registry prefix (e.g., "registry.example.com/myimage:tag" -> "myimage:tag")
if (baseImageName.includes('/')) {
const parts = baseImageName.split('/');
// Check if first part looks like a registry (contains . or :)
if (parts[0].includes('.') || parts[0].includes(':')) {
baseImageName = parts.slice(1).join('/');
}
}
// Build the target tag
const registryUrl = new URL(registry.url);
const registryHost = registryUrl.host;
// Check if this is Docker Hub
const isDockerHub = registryHost.includes('docker.io') ||
registryHost.includes('hub.docker.com') ||
registryHost.includes('registry.hub.docker.com') ||
registryHost.includes('index.docker.io');
// 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}`;
// 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)
const lastSlashIndex = targetTag.lastIndexOf('/');
const tagPart = targetTag.substring(lastSlashIndex + 1);
const colonInTagIndex = tagPart.lastIndexOf(':');
let repo: string;
let tag: string;
if (colonInTagIndex !== -1) {
// Tag exists after the last slash
repo = targetTag.substring(0, lastSlashIndex + 1 + colonInTagIndex);
tag = tagPart.substring(colonInTagIndex + 1);
} else {
// No tag, use 'latest'
repo = targetTag;
tag = 'latest';
}
// Prepare auth config
// Docker Hub uses index.docker.io/v1 for auth
const authServerAddress = isDockerHub ? 'https://index.docker.io/v1/' : registryHost;
const authConfig = registry.username && registry.password
? {
username: registry.username,
password: registry.password,
serveraddress: authServerAddress
}
: {
serveraddress: authServerAddress
};
// Check if this is an edge environment
const edgeCheck = await isEdgeMode(envIdNum);
// Stream the push progress
const encoder = new TextEncoder();
let controllerClosed = false;
let controller: ReadableStreamDefaultController<Uint8Array>;
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
let cancelEdgeStream: (() => void) | null = null;
const safeEnqueue = (data: string) => {
if (!controllerClosed) {
try {
controller.enqueue(encoder.encode(data));
} catch {
controllerClosed = true;
}
}
};
const cleanup = () => {
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
}
if (cancelEdgeStream) {
cancelEdgeStream();
cancelEdgeStream = null;
}
controllerClosed = true;
};
const formatError = (error: any): string => {
const errorMessage = error.message || error || '';
let userMessage = errorMessage || 'Failed to push image';
if (error.statusCode === 401 || errorMessage.includes('401')) {
userMessage = 'Authentication failed. Check registry credentials.';
} else if (error.statusCode === 404 || errorMessage.includes('404')) {
userMessage = 'Image not found';
} else if (errorMessage.includes('https') || errorMessage.includes('tls') || errorMessage.includes('certificate') || errorMessage.includes('x509')) {
userMessage = `TLS/HTTPS error. If your registry uses HTTP, add it to Docker's insecure-registries in /etc/docker/daemon.json`;
}
return userMessage;
};
const stream = new ReadableStream({
async start(ctrl) {
controller = ctrl;
// Start heartbeat to keep connection alive through Traefik (10s idle timeout)
heartbeatInterval = setInterval(() => {
safeEnqueue(`: keepalive\n\n`);
}, 5000);
try {
// Send tagging status
safeEnqueue(`data: ${JSON.stringify({ status: 'tagging', message: 'Tagging image...' })}\n\n`);
// Tag the image with the target registry
await tagImage(imageId, repo, tag, envIdNum);
// Send pushing status
safeEnqueue(`data: ${JSON.stringify({ status: 'pushing', message: 'Pushing to registry...' })}\n\n`);
// Handle edge mode with streaming
if (edgeCheck.isEdge && edgeCheck.environmentId) {
if (!isEdgeConnected(edgeCheck.environmentId)) {
safeEnqueue(`data: ${JSON.stringify({ status: 'error', error: 'Edge agent not connected' })}\n\n`);
cleanup();
controller.close();
return;
}
// Create X-Registry-Auth header
const authHeader = Buffer.from(JSON.stringify(authConfig)).toString('base64');
const { cancel } = sendEdgeStreamRequest(
edgeCheck.environmentId,
'POST',
`/images/${encodeURIComponent(targetTag)}/push`,
{
onData: (data: string) => {
// Data is base64 encoded JSON lines from Docker
try {
const decoded = Buffer.from(data, 'base64').toString('utf-8');
const lines = decoded.split('\n').filter(line => line.trim());
for (const line of lines) {
try {
const progress = JSON.parse(line);
if (progress.error) {
safeEnqueue(`data: ${JSON.stringify({ status: 'error', error: formatError(progress.error) })}\n\n`);
} else {
safeEnqueue(`data: ${JSON.stringify(progress)}\n\n`);
}
} catch {
// Ignore parse errors for partial lines
}
}
} catch {
// If not base64, try as-is
try {
const progress = JSON.parse(data);
if (progress.error) {
safeEnqueue(`data: ${JSON.stringify({ status: 'error', error: formatError(progress.error) })}\n\n`);
} else {
safeEnqueue(`data: ${JSON.stringify(progress)}\n\n`);
}
} catch {
// Ignore
}
}
},
onEnd: async () => {
// Audit log
await auditImage(event, 'push', imageId, imageName || targetTag, envIdNum, { targetTag, registry: registry.name });
safeEnqueue(`data: ${JSON.stringify({
status: 'complete',
message: `Image pushed to ${targetTag}`,
targetTag
})}\n\n`);
cleanup();
controller.close();
},
onError: (error: string) => {
console.error('Edge push error:', error);
safeEnqueue(`data: ${JSON.stringify({ status: 'error', error: formatError(error) })}\n\n`);
cleanup();
controller.close();
}
},
undefined,
{ 'X-Registry-Auth': authHeader }
);
cancelEdgeStream = cancel;
} else {
// Non-edge mode: use existing pushImage function
await pushImage(targetTag, authConfig, (progress) => {
safeEnqueue(`data: ${JSON.stringify(progress)}\n\n`);
}, envIdNum);
// Audit log
await auditImage(event, 'push', imageId, imageName || targetTag, envIdNum, { targetTag, registry: registry.name });
// Send completion message
safeEnqueue(`data: ${JSON.stringify({
status: 'complete',
message: `Image pushed to ${targetTag}`,
targetTag
})}\n\n`);
cleanup();
controller.close();
}
} catch (error: any) {
console.error('Error pushing image:', error);
safeEnqueue(`data: ${JSON.stringify({
status: 'error',
error: formatError(error)
})}\n\n`);
cleanup();
controller.close();
}
},
cancel() {
cleanup();
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no'
}
});
} catch (error: any) {
console.error('Error setting up push:', error);
return json({ error: error.message || 'Failed to push image' }, { status: 500 });
}
};
+149
View File
@@ -0,0 +1,149 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { scanImage, type ScanProgress, type ScanResult } from '$lib/server/scanner';
import { saveVulnerabilityScan, getLatestScanForImage } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
// Helper to convert ScanResult to database format
function scanResultToDbFormat(result: ScanResult, envId?: number) {
return {
environmentId: envId ?? null,
imageId: result.imageId || result.imageName, // Fallback to imageName if imageId is undefined
imageName: result.imageName,
scanner: result.scanner,
scannedAt: result.scannedAt,
scanDuration: result.scanDuration,
criticalCount: result.summary.critical,
highCount: result.summary.high,
mediumCount: result.summary.medium,
lowCount: result.summary.low,
negligibleCount: result.summary.negligible,
unknownCount: result.summary.unknown,
vulnerabilities: JSON.stringify(result.vulnerabilities),
error: result.error ?? null
};
}
// POST - Start a scan (returns SSE stream for progress)
export const POST: RequestHandler = async ({ request, url, cookies }) => {
const auth = await authorize(cookies);
const envIdParam = url.searchParams.get('env');
const envId = envIdParam ? parseInt(envIdParam) : undefined;
// Permission check with environment context (Scanning is an inspect operation)
if (auth.authEnabled && !await auth.can('images', 'inspect', envId)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const body = await request.json();
const { imageName, scanner: forceScannerType } = body;
if (!imageName) {
return json({ error: 'Image name is required' }, { status: 400 });
}
// Create a readable stream for SSE
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
let controllerClosed = false;
const sendProgress = (progress: ScanProgress) => {
if (controllerClosed) return;
try {
const data = `data: ${JSON.stringify(progress)}\n\n`;
controller.enqueue(encoder.encode(data));
} catch {
controllerClosed = true;
}
};
// Send SSE keepalive comments every 5s to prevent Traefik timeout
const keepaliveInterval = setInterval(() => {
if (controllerClosed) return;
try {
controller.enqueue(encoder.encode(`: keepalive\n\n`));
} catch {
controllerClosed = true;
}
}, 5000);
try {
const results = await scanImage(imageName, envId, sendProgress, forceScannerType);
// Save results to database
for (const result of results) {
await saveVulnerabilityScan(scanResultToDbFormat(result, envId));
}
// Send final complete message with all results
sendProgress({
stage: 'complete',
message: `Scan complete - found ${results.reduce((sum, r) => sum + r.vulnerabilities.length, 0)} vulnerabilities`,
progress: 100,
result: results[0],
results: results // Include all scanner results
});
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
sendProgress({
stage: 'error',
message: `Scan failed: ${errorMsg}`,
error: errorMsg
});
} finally {
clearInterval(keepaliveInterval);
if (!controllerClosed) {
try {
controller.close();
} catch {
// Already closed
}
}
}
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
}
});
};
// GET - Get cached scan results for an image
export const GET: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
const imageName = url.searchParams.get('image');
const envIdParam = url.searchParams.get('env');
const envId = envIdParam ? parseInt(envIdParam) : undefined;
const scanner = url.searchParams.get('scanner') as 'grype' | 'trivy' | undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('images', 'view', envId)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
if (!imageName) {
return json({ error: 'Image name is required' }, { status: 400 });
}
try {
// Note: getLatestScanForImage signature is (imageId, scanner, environmentId)
const result = await getLatestScanForImage(imageName, scanner, envId);
if (!result) {
return json({ found: false });
}
return json({
found: true,
result
});
} catch (error) {
console.error('Failed to get scan results:', error);
return json({ error: 'Failed to get scan results' }, { status: 500 });
}
};
Loaded 100 of 556 files, more files were not shown because too many files have changed in this diff. Show more