This commit is contained in:
jarek committed 2026-01-22 16:23:26 +01:00
1 parent a3cc26d958
commit 86a06d9de0
32 files changed
+799 -522

No files matched your search

+14 -1
View File
@@ -12,9 +12,11 @@ import {
isAuthEnabled
} from '$lib/server/auth';
import { getUser, getUserByUsername } from '$lib/server/db';
import { auditAuth } from '$lib/server/audit';
// POST /api/auth/login - Authenticate user
export const POST: RequestHandler = async ({ request, cookies, getClientAddress }) => {
export const POST: RequestHandler = async (event) => {
const { request, cookies, getClientAddress } = event;
// Check if auth is enabled
if (!(await isAuthEnabled())) {
return json({ error: 'Authentication is not enabled' }, { status: 400 });
@@ -80,6 +82,12 @@ export const POST: RequestHandler = async ({ request, cookies, getClientAddress
const session = await createUserSession(user.id, authProviderType, cookies);
clearRateLimit(rateLimitKey);
// Audit log
await auditAuth(event, 'login', user.username, {
provider: authProviderType,
mfa: true
});
return json({
success: true,
user: {
@@ -97,6 +105,11 @@ export const POST: RequestHandler = async ({ request, cookies, getClientAddress
const session = await createUserSession(result.user.id, authProviderType, cookies);
clearRateLimit(rateLimitKey);
// Audit log
await auditAuth(event, 'login', result.user.username, {
provider: authProviderType
});
return json({
success: true,
user: {
+12 -1
View File
@@ -1,11 +1,22 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { destroySession } from '$lib/server/auth';
import { authorize } from '$lib/server/authorize';
import { auditAuth } from '$lib/server/audit';
// POST /api/auth/logout - End session
export const POST: RequestHandler = async ({ cookies }) => {
export const POST: RequestHandler = async (event) => {
const { cookies } = event;
try {
// Get current user before destroying session for audit log
const auth = await authorize(cookies);
const username = auth.user?.username || 'unknown';
await destroySession(cookies);
// Audit log
await auditAuth(event, 'logout', username);
return json({ success: true });
} catch (error) {
console.error('Logout error:', error);
+10 -1
View File
@@ -1,9 +1,11 @@
import { json, redirect } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { handleOidcCallback, createUserSession, isAuthEnabled } from '$lib/server/auth';
import { auditAuth } from '$lib/server/audit';
// GET /api/auth/oidc/callback - Handle OIDC callback from IdP
export const GET: RequestHandler = async ({ url, cookies }) => {
export const GET: RequestHandler = async (event) => {
const { url, cookies } = event;
// Check if auth is enabled
if (!isAuthEnabled()) {
throw redirect(302, '/login?error=auth_disabled');
@@ -38,6 +40,13 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
// Create session
await createUserSession(result.user.id, 'oidc', cookies);
// Audit log
await auditAuth(event, 'login', result.user.username, {
provider: 'oidc',
providerId: result.providerId,
providerName: result.providerName
});
// Redirect to the original destination or home
const redirectUrl = result.redirectUrl || '/';
throw redirect(302, redirectUrl);
+10 -1
View File
@@ -21,7 +21,7 @@ import {
downStack,
removeStack
} from '$lib/server/stacks';
import { deleteAutoUpdateSchedule, getAutoUpdateSetting } from '$lib/server/db';
import { deleteAutoUpdateSchedule, getAutoUpdateSetting, removePendingContainerUpdate } from '$lib/server/db';
import { unregisterSchedule } from '$lib/server/scheduler';
// SSE Event types
@@ -375,6 +375,15 @@ async function executeContainerOperation(
} catch {
// Ignore cleanup errors
}
// Clean up pending container update if exists
try {
if (envIdNum) {
await removePendingContainerUpdate(envIdNum, id);
}
} catch {
// Ignore cleanup errors
}
break;
default:
throw new Error(`Unsupported container operation: ${operation}`);
+11 -1
View File
@@ -4,7 +4,7 @@ import {
removeContainer,
getContainerLogs
} from '$lib/server/docker';
import { deleteAutoUpdateSchedule, getAutoUpdateSetting } from '$lib/server/db';
import { deleteAutoUpdateSchedule, getAutoUpdateSetting, removePendingContainerUpdate } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import { unregisterSchedule } from '$lib/server/scheduler';
@@ -85,6 +85,16 @@ export const DELETE: RequestHandler = async (event) => {
// Don't fail the deletion if schedule cleanup fails
}
// Clean up pending container update if exists
try {
if (envIdNum) {
await removePendingContainerUpdate(envIdNum, params.id);
}
} catch (error) {
console.error('Failed to cleanup pending container update:', error);
// Don't fail the deletion if cleanup fails
}
return json({ success: true });
} catch (error) {
console.error('Error removing container:', error);
@@ -182,6 +182,22 @@ export const POST: RequestHandler = async (event) => {
continue;
}
// Skip digest-pinned images - they are explicitly locked to a specific version
if (isDigestBasedImage(imageName)) {
safeEnqueue({
type: 'progress',
containerId,
containerName,
step: 'skipped',
current: i + 1,
total: containerIds.length,
success: true,
message: `Skipping ${containerName} - image pinned to specific digest`
});
skippedCount++;
continue;
}
// Step 1: Pull latest image
safeEnqueue({
type: 'progress',
@@ -559,8 +575,18 @@ export const POST: RequestHandler = async (event) => {
: `Updated ${successCount} of ${containerIds.length} containers`
});
clearInterval(keepaliveInterval);
controller.close();
if (keepaliveInterval) {
clearInterval(keepaliveInterval);
}
if (!controllerClosed) {
try {
controller.close();
controllerClosed = true;
} catch {
// Controller already closed - ignore
controllerClosed = true;
}
}
},
cancel() {
controllerClosed = true;
+5 -1
View File
@@ -20,6 +20,9 @@ import { listComposeStacks } from '$lib/server/stacks';
import { authorize } from '$lib/server/authorize';
import { parseLabels } from '$lib/utils/label-colors';
// Skip disk usage collection (Synology NAS performance fix)
const SKIP_DF_COLLECTION = process.env.SKIP_DF_COLLECTION === 'true' || process.env.SKIP_DF_COLLECTION === '1';
// Helper to add timeout to promises
function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T> {
return Promise.race([
@@ -200,13 +203,14 @@ export const GET: RequestHandler = async ({ cookies, url }) => {
envStats.online = true;
// Fetch all data in parallel (with 10 second timeout per operation)
// Disk usage can be disabled with SKIP_DF_COLLECTION for Synology NAS devices
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)
SKIP_DF_COLLECTION ? Promise.resolve(null) : withTimeout(getDiskUsage(env.id).catch(() => null), 10000, null)
]);
// Process containers
@@ -21,6 +21,9 @@ import { authorize } from '$lib/server/authorize';
import type { EnvironmentStats } from '../+server';
import { parseLabels } from '$lib/utils/label-colors';
// Skip disk usage collection (Synology NAS performance fix)
const SKIP_DF_COLLECTION = process.env.SKIP_DF_COLLECTION === 'true' || process.env.SKIP_DF_COLLECTION === '1';
// Helper to add timeout to promises
function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T> {
return Promise.race([
@@ -31,6 +34,7 @@ function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T
// Disk usage cache - getDiskUsage() is very slow (30s timeout) but data changes rarely
// Cache per environment with 5-minute TTL
// DISABLED when SKIP_DF_COLLECTION is set (kills Synology NAS devices)
interface DiskUsageCache {
data: any;
timestamp: number;
@@ -344,37 +348,49 @@ async function getEnvironmentStatsProgressive(
});
// 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;
}
// Can be disabled with SKIP_DF_COLLECTION env var for Synology NAS
const diskUsagePromise = SKIP_DF_COLLECTION
? Promise.resolve(null).then(() => {
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 null;
})
: 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;
return diskUsage;
});
// 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
+38 -9
View File
@@ -36,11 +36,30 @@ export const GET: RequestHandler = async ({ url }) => {
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
let controllerClosed = false;
// Safe close helper - prevents "Controller is already closed" errors
const safeClose = () => {
if (controllerClosed) return;
try {
controller.close();
controllerClosed = true;
} catch {
// Controller already closed - ignore
controllerClosed = true;
}
};
// 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));
if (controllerClosed) return;
try {
const event = `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`;
controller.enqueue(encoder.encode(event));
} catch {
// Controller closed or errored - mark as closed
controllerClosed = true;
}
};
// Send heartbeat to keep connection alive (every 5s to prevent Traefik 10s idle timeout)
@@ -64,7 +83,7 @@ export const GET: RequestHandler = async ({ url }) => {
if (!eventStream) {
sendEvent('error', { message: 'Failed to connect to Docker events' });
clearInterval(heartbeatInterval);
controller.close();
safeClose();
return;
}
@@ -108,11 +127,17 @@ export const GET: RequestHandler = async ({ url }) => {
}
}
} catch (error: any) {
console.error('Docker event stream error:', error);
sendEvent('error', { message: error.message });
// Don't log full stack trace for expected connection errors
const isConnectionError = error?.code === 'ECONNRESET' || error?.code === 'ECONNREFUSED';
if (isConnectionError) {
// Silent - these are handled by event-subprocess reconnection logic
} else {
console.error('Docker event stream error:', error?.message || error);
}
sendEvent('error', { message: error?.message || 'Stream connection lost' });
} finally {
clearInterval(heartbeatInterval);
controller.close();
safeClose();
}
};
@@ -122,11 +147,15 @@ export const GET: RequestHandler = async ({ url }) => {
// Expected error when environment doesn't exist - don't spam logs
sendEvent('error', { message: 'Environment not found' });
} else {
console.error('Failed to connect to Docker events:', error);
sendEvent('error', { message: error.message || 'Failed to connect to Docker' });
// Don't log full stack trace for expected connection errors
const isConnectionError = error?.code === 'ECONNRESET' || error?.code === 'ECONNREFUSED';
if (!isConnectionError) {
console.error('Failed to connect to Docker events:', error?.message || error);
}
sendEvent('error', { message: error?.message || 'Failed to connect to Docker' });
}
clearInterval(heartbeatInterval);
controller.close();
safeClose();
}
}
});
+3 -2
View File
@@ -1,6 +1,6 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getGitStack, updateGitStack, deleteGitStack, deleteStackSource, updateStackSourceName } from '$lib/server/db';
import { getGitStack, updateGitStack, deleteGitStack, deleteStackSource, updateStackSourceName, updateStackEnvVarsName } from '$lib/server/db';
import { deleteGitStackFiles, deployGitStack } from '$lib/server/git';
import { authorize } from '$lib/server/authorize';
import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler';
@@ -71,9 +71,10 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
webhookSecret: data.webhookSecret
});
// If stack name changed, update the stack_sources record too
// If stack name changed, update related records
if (data.stackName && data.stackName !== oldStackName) {
await updateStackSourceName(oldStackName, data.stackName, existing.environmentId);
await updateStackEnvVarsName(oldStackName, data.stackName, existing.environmentId);
}
// Register or unregister schedule with croner
+8
View File
@@ -3,6 +3,9 @@ import { getDiskUsage } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import type { RequestHandler } from './$types';
// Skip disk usage collection (Synology NAS performance fix)
const SKIP_DF_COLLECTION = process.env.SKIP_DF_COLLECTION === 'true' || process.env.SKIP_DF_COLLECTION === '1';
const DISK_USAGE_TIMEOUT = 15000; // 15 second timeout
export const GET: RequestHandler = async ({ url, cookies }) => {
@@ -23,6 +26,11 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
return json({ error: 'Access denied to this environment' }, { status: 403 });
}
// Skip disk usage when disabled (Synology NAS performance fix)
if (SKIP_DF_COLLECTION) {
return json({ diskUsage: null });
}
try {
// Fetch disk usage with timeout
const diskUsagePromise = getDiskUsage(envId);