fix: harden security, fix concurrency bugs, and address review findings
Build / build (push) Successful in 11m42s

Security:
- rate limit /api/webhook routes per-IP and cap concurrent site syncs
- global SSE connection cap (256) with new sse_gate
- validate ?tail= and cap JSON log responses at 4 MiB
- strip ANSI/CSI/OSC and control bytes from streamed log lines
- redact webhook secret from request log middleware
- scrub host details from /api/health for non-admin viewers
- drop container_id from /api/system/stats/top for non-admins
- generate webhook secrets via crypto/rand; require >=32 chars on insert
- verify iid path consistency in streamContainerLogs
- LimitReader on site webhook body; reject malformed non-empty bodies

Concurrency / correctness:
- stats collector: Stop() no longer hangs without Start(), semaphore
  acquired in parent loop so ctx cancellation short-circuits the queue,
  in-flight tick cancellable via shared base context, zero-ts guard
- webhook handler: replace fire-and-forget goroutine with WaitGroup-tracked
  workers + Drain() wired into graceful shutdown
- $derived(() => ...) mis-idiom fixed in ContainerStats / InstanceCard /
  ProjectCard (returned function instead of value)
- SystemResourcesCard: rename `window` and `t` locals to avoid shadowing
  globalThis.window and the i18n `t` import

Quality / performance:
- replace O(n^2) insertion sort with sort.Slice in stats top
- runMigrations only swallows duplicate-column / already-exists errors
- PruneStatsSamplesBefore wrapped in a transaction
- collapse N+1 in unusedImageStats / pruneImages to one ListAllInstances
  pass; surface DB errors instead of silently treating them as inactive
- run Docker Info + DiskUsage in parallel via errgroup
- container log SSE emits `: ping` heartbeat every 20 s
- imageMatches case-insensitive on registry host (RFC behaviour)
- log warning on invalid stage tag pattern instead of silent skip
- reject malformed non-empty site webhook payloads

Frontend / i18n:
- shared formatBytes utility replaces three local copies
- statsInterval store drives dynamic "no samples / collection disabled"
  copy across ContainerStats and SystemResourcesCard
- top consumers row now shows owner_name (project/stage or site name)
- drop seven `as any` casts on the Settings type; add cloudflare_api_token
  write-only field
- move "Service status", "Docker daemon", "Docker unreachable",
  "Proxy unreachable", "reachable", and "Docker daemon is not reachable."
  strings into en/ru i18n bundles
This commit is contained in:
2026-05-07 00:56:14 +03:00
parent 05440a5f92
commit a4362b842d
39 changed files with 1249 additions and 213 deletions
+20 -12
View File
@@ -14,6 +14,8 @@
import { t } from '$lib/i18n';
import { navCounts, startNavCountsPolling, stopNavCountsPolling, refreshNavCounts } from '$lib/stores/navCounts';
import { health, startHealthPolling, stopHealthPolling, refreshHealth } from '$lib/stores/health';
import { effectiveTimezone, formatOffsetLabel } from '$lib/stores/timezone';
import { fmt } from '$lib/format/datetime';
interface Props {
children: Snippet;
@@ -54,15 +56,17 @@
const proxyHealth = $derived($health.proxy);
const healthChecked = $derived($health.checked);
// Live UTC forge clock (refreshes every second). A small thing, but it makes
// Live forge clock (refreshes every second). A small thing, but it makes
// the sidebar feel alive and reinforces the "control room" aesthetic.
let nowUtc = $state('');
// Renders in the user's chosen timezone via the shared formatter.
let nowTick = $state(new Date());
let clockTimer: ReturnType<typeof setInterval> | null = null;
function tickClock() {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
nowUtc = `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
nowTick = new Date();
}
const clockDisplay = $derived($fmt.clock(nowTick));
const clockOffset = $derived(formatOffsetLabel($effectiveTimezone, nowTick));
const clockTitle = $derived(`${$effectiveTimezone.replace(/_/g, ' ')} · ${clockOffset}`);
// Keyboard quick-nav: "g" then a letter jumps to a section (vim-style).
// g+d → dashboard, g+p → projects, g+s → sites, g+k → stacks, g+x → deploy,
@@ -194,14 +198,16 @@
</div>
<!-- Daemon health chips (Docker + proxy provider) -->
<div class="brand-rail" aria-label="Service status">
<div class="brand-rail" aria-label={$t('layout.serviceStatus')}>
{#if healthChecked}
<button
type="button"
class="chip"
class:chip-live={dockerConnected}
class:chip-down={!dockerConnected}
title={dockerConnected ? `Docker daemon · ${dockerHealth?.version ?? 'reachable'}` : dockerHealth?.error ?? 'Docker unreachable'}
title={dockerConnected
? `${$t('daemons.docker')} · ${dockerHealth?.version ?? $t('daemons.reachable')}`
: dockerHealth?.error ?? $t('daemons.dockerUnreachable')}
onclick={() => { if (!dockerConnected) hintsExpanded = !hintsExpanded; }}
>
<span class="chip-dot" aria-hidden="true"></span>
@@ -218,7 +224,9 @@
class="chip"
class:chip-live={proxyConnected}
class:chip-down={!proxyConnected}
title={proxyConnected ? `${proxyProviderName.toUpperCase()} · ${proxyHealth.latency_ms ?? '?'} ms` : proxyHealth.error ?? 'Proxy unreachable'}
title={proxyConnected
? `${proxyProviderName.toUpperCase()} · ${proxyHealth.latency_ms ?? '?'} ms`
: proxyHealth.error ?? $t('daemons.proxyUnreachable')}
onclick={() => { if (!proxyConnected) proxyHintsExpanded = !proxyHintsExpanded; }}
>
<span class="chip-dot" aria-hidden="true"></span>
@@ -323,10 +331,10 @@
</div>
<div class="forge-footline">
<span class="forge-footline-version">{$t('app.name')} {$t('app.version')}</span>
<span class="forge-footline-clock" title="UTC">
<span class="forge-footline-clock" title={clockTitle}>
<span class="clock-dot"></span>
<span class="clock-time">{nowUtc || '--:--:--'}</span>
<span class="clock-suffix">UTC</span>
<span class="clock-time">{clockDisplay}</span>
<span class="clock-suffix">{clockOffset}</span>
</span>
</div>
<p class="forge-nav-hint" title="Press 'g' then a letter to jump between sections">
@@ -599,7 +607,7 @@
color: var(--text-primary);
}
/* ── Sidebar footline (version + live UTC clock) ───────────── */
/* ── Sidebar footline (version + live timezone-aware clock) ───────────── */
.forge-footline {
display: flex;
align-items: center;
+1 -1
View File
@@ -109,7 +109,7 @@
polling_interval: secondsToDuration(pollingInterval),
base_volume_path: baseVolumePath.trim(),
proxy_provider: proxyProvider
} as any);
});
toasts.success($t('settingsGeneral.saved'));
} catch (err) {
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.saveFailed'));
+1 -1
View File
@@ -54,7 +54,7 @@
backup_enabled: backupEnabled,
backup_interval_hours: Math.max(1, parseInt(backupIntervalHours, 10) || 24),
backup_retention_count: Math.max(1, parseInt(backupRetentionCount, 10) || 10)
} as any);
});
toasts.success($t('settingsBackup.saved'));
} catch (err) {
toasts.error(err instanceof Error ? err.message : $t('settingsBackup.saveFailed'));
+7 -7
View File
@@ -7,10 +7,11 @@
-->
<script lang="ts">
import { getSettings, updateSettings, testDnsConnection, listDnsZones } from '$lib/api';
import type { EntityPickerItem } from '$lib/types';
import type { EntityPickerItem, Settings } from '$lib/types';
import FormField from '$lib/components/FormField.svelte';
import EntityPicker from '$lib/components/EntityPicker.svelte';
import Skeleton from '$lib/components/Skeleton.svelte';
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
import { toasts } from '$lib/stores/toast';
import { t } from '$lib/i18n';
import { IconLoader, IconX } from '$lib/components/icons';
@@ -50,13 +51,13 @@
async function handleSave() {
saving = true;
try {
const payload: Record<string, unknown> = {
const payload: Partial<Settings> = {
wildcard_dns: wildcardDns,
dns_provider: wildcardDns ? '' : dnsProvider,
cloudflare_zone_id: cloudflareZoneId
};
if (cloudflareApiToken) payload.cloudflare_api_token = cloudflareApiToken;
await updateSettings(payload as any);
await updateSettings(payload);
toasts.success($t('settingsGeneral.saved'));
cloudflareApiToken = '';
hasCloudflareApiToken = hasCloudflareApiToken || Boolean(payload.cloudflare_api_token);
@@ -144,14 +145,13 @@
<h2 class="mb-1 text-lg font-semibold text-[var(--text-primary)]">{$t('settingsDns.title')}</h2>
<p class="mb-4 text-sm text-[var(--text-secondary)]">{$t('settingsDns.description')}</p>
<label class="flex items-center gap-3 cursor-pointer">
<input type="checkbox" bind:checked={wildcardDns}
class="h-4 w-4 rounded border-[var(--border-primary)] text-[var(--color-brand-600)] focus:ring-[var(--color-brand-500)]" />
<div class="flex items-center gap-3">
<ToggleSwitch bind:checked={wildcardDns} label={$t('settingsGeneral.wildcardDns')} />
<div>
<span class="text-sm font-medium text-[var(--text-primary)]">{$t('settingsGeneral.wildcardDns')}</span>
<p class="text-xs text-[var(--text-tertiary)]">{$t('settingsGeneral.wildcardDnsHelp')}</p>
</div>
</label>
</div>
{#if !wildcardDns}
<div class="mt-4 space-y-4 rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card-hover)] p-4">
@@ -42,7 +42,7 @@
if (urlErr) return;
saving = true;
try {
await updateSettings({ notification_url: notificationUrl.trim() } as any);
await updateSettings({ notification_url: notificationUrl.trim() });
toasts.success($t('settingsGeneral.saved'));
} catch (err) {
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.saveFailed'));
@@ -56,7 +56,7 @@
image_prune_threshold_mb: Math.max(0, parseInt(imagePruneThresholdMb, 10) || 0),
stats_interval_seconds: interval,
stats_retention_hours: retention
} as any);
});
toasts.success($t('settingsGeneral.saved'));
} catch (err) {
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.saveFailed'));
+2 -2
View File
@@ -145,7 +145,7 @@
}
async function saveAccessList(id: number) {
try { await updateSettings({ npm_access_list_id: id } as any); toasts.success($t('settingsCredentials.saved')); }
try { await updateSettings({ npm_access_list_id: id }); toasts.success($t('settingsCredentials.saved')); }
catch (err) { toasts.error(err instanceof Error ? err.message : $t('settingsCredentials.saveFailed')); }
}
@@ -179,7 +179,7 @@
async function handleNpmRemoteChange() {
try {
await updateSettings({ npm_remote: npmRemote } as any);
await updateSettings({ npm_remote: npmRemote });
toasts.success($t('settingsCredentials.saved'));
} catch (err) {
toasts.error(err instanceof Error ? err.message : $t('settingsCredentials.saveFailed'));
+1 -1
View File
@@ -35,7 +35,7 @@
traefik_cert_resolver: traefikCertResolver.trim(),
traefik_network: traefikNetwork.trim(),
traefik_api_url: traefikApiUrl.trim()
} as any);
});
toasts.success($t('settingsGeneral.saved'));
} catch (err) {
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.saveFailed'));