mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-08 21:29:05 +00:00
Initial commit
This commit is contained in:
39
routes/api/images/+server.ts
Normal file
39
routes/api/images/+server.ts
Normal 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
routes/api/images/[id]/+server.ts
Normal file
61
routes/api/images/[id]/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
79
routes/api/images/[id]/export/+server.ts
Normal file
79
routes/api/images/[id]/export/+server.ts
Normal file
@@ -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 });
|
||||
}
|
||||
};
|
||||
24
routes/api/images/[id]/history/+server.ts
Normal file
24
routes/api/images/[id]/history/+server.ts
Normal file
@@ -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
routes/api/images/[id]/tag/+server.ts
Normal file
28
routes/api/images/[id]/tag/+server.ts
Normal 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
routes/api/images/pull/+server.ts
Normal file
265
routes/api/images/pull/+server.ts
Normal 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
routes/api/images/push/+server.ts
Normal file
303
routes/api/images/push/+server.ts
Normal 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
routes/api/images/scan/+server.ts
Normal file
149
routes/api/images/scan/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user