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
+3 -2
View File
@@ -4,6 +4,7 @@ import type {
ContainerStatsSample,
SystemStats,
SystemStatsSample,
TopContainerSample,
Deploy,
DeployLog,
DockerHealth,
@@ -708,8 +709,8 @@ export function fetchTopContainers(
by: 'cpu' | 'memory' = 'cpu',
limit = 5,
signal?: AbortSignal
): Promise<ContainerStatsSample[]> {
return get<ContainerStatsSample[]>(`/api/system/stats/top?by=${by}&limit=${limit}`, signal);
): Promise<TopContainerSample[]> {
return get<TopContainerSample[]>(`/api/system/stats/top?by=${by}&limit=${limit}`, signal);
}
export function fetchStaticSiteStats(id: string, signal?: AbortSignal): Promise<ContainerStats> {
+9 -14
View File
@@ -7,6 +7,7 @@
import type { ContainerStats, ContainerStatsSample } from '$lib/types';
import * as api from '$lib/api';
import { t } from '$lib/i18n';
import { statsInterval } from '$lib/stores/statsInterval';
import ResourceChart from './ResourceChart.svelte';
import type { EChartsOption } from 'echarts';
@@ -74,24 +75,16 @@
};
});
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
const kb = bytes / 1024;
if (kb < 1024) return `${kb.toFixed(0)} KB`;
const mb = kb / 1024;
if (mb < 1024) return `${mb.toFixed(1)} MB`;
const gb = mb / 1024;
return `${gb.toFixed(2)} GB`;
}
import { formatBytes } from '$lib/format/bytes';
const cpuColor = $derived(() => {
const cpuColor = $derived.by(() => {
if (!stats) return 'bg-gray-300';
if (stats.cpu_percent > 80) return 'bg-red-500';
if (stats.cpu_percent > 50) return 'bg-amber-500';
return 'bg-emerald-500';
});
const memColor = $derived(() => {
const memColor = $derived.by(() => {
if (!stats) return 'bg-gray-300';
if (stats.memory_percent > 80) return 'bg-red-500';
if (stats.memory_percent > 50) return 'bg-amber-500';
@@ -151,7 +144,7 @@
<span class="w-8 text-[10px] font-medium text-[var(--text-tertiary)]">{$t('stats.cpu')}</span>
<div class="relative h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--surface-card-hover)]">
<div
class="absolute inset-y-0 left-0 rounded-full transition-all duration-500 {cpuColor()}"
class="absolute inset-y-0 left-0 rounded-full transition-all duration-500 {cpuColor}"
style="width: {Math.min(stats.cpu_percent, 100)}%"
></div>
</div>
@@ -164,7 +157,7 @@
<span class="w-8 text-[10px] font-medium text-[var(--text-tertiary)]">{$t('stats.mem')}</span>
<div class="relative h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--surface-card-hover)]">
<div
class="absolute inset-y-0 left-0 rounded-full transition-all duration-500 {memColor()}"
class="absolute inset-y-0 left-0 rounded-full transition-all duration-500 {memColor}"
style="width: {Math.min(stats.memory_percent, 100)}%"
></div>
</div>
@@ -184,7 +177,9 @@
{#if expanded}
{#if history.length === 0}
<p class="mt-1 text-[10px] text-[var(--text-tertiary)]">
{$t('resources.noSamples', { interval: '15' })}
{$statsInterval > 0
? $t('resources.noSamples', { interval: String($statsInterval) })
: $t('resources.collectionDisabled')}
</p>
{:else}
<div class="mt-1 rounded-md border border-[var(--border-primary)] bg-[var(--surface-page)] p-2">
+2 -2
View File
@@ -32,7 +32,7 @@
: instance.subdomain ? `https://${instance.subdomain}` : ''
);
const timeSinceCreated = $derived(() => $fmt.relative(instance.created_at));
const timeSinceCreated = $derived($fmt.relative(instance.created_at));
async function handleAction(action: 'stop' | 'start' | 'restart' | 'remove') {
loading = true;
@@ -90,7 +90,7 @@
<div class="mt-1.5 flex items-center gap-3 text-xs text-[var(--text-tertiary)]">
<span class="rounded bg-[var(--surface-card-hover)] px-1.5 py-0.5 font-mono">:{instance.port}</span>
<span>{timeSinceCreated()}</span>
<span>{timeSinceCreated}</span>
</div>
</div>
+2 -2
View File
@@ -19,7 +19,7 @@
const failedCount = $derived(instances.filter((i) => i.status === 'failed').length);
const totalCount = $derived(instances.length);
const overallStatus = $derived<string>(() => {
const overallStatus = $derived.by<'failed' | 'running' | 'stopped'>(() => {
if (failedCount > 0) return 'failed';
if (runningCount > 0) return 'running';
if (stoppedCount > 0) return 'stopped';
@@ -41,7 +41,7 @@
</div>
<p class="mt-2 truncate font-mono text-xs text-[var(--text-tertiary)]">{project.image}</p>
</div>
<StatusBadge status={overallStatus()} size="sm" />
<StatusBadge status={overallStatus} size="sm" />
</div>
<!-- Instance count badges -->
@@ -36,12 +36,11 @@
totalContainers > 0 ? ((docker?.stopped ?? 0) / totalContainers) * 100 : 0
);
import { formatBytes as formatBytesShared } from '$lib/format/bytes';
function formatBytes(n: number | undefined): string {
if (!n || n <= 0) return '—';
const gb = n / 1024 ** 3;
if (gb >= 1) return `${gb.toFixed(1)} GB`;
const mb = n / 1024 ** 2;
return `${mb.toFixed(0)} MB`;
return formatBytesShared(n);
}
function formatMs(n: number | undefined): string {
@@ -95,7 +94,7 @@
</div>
{:else if !dockerConnected}
<div class="panel-error">
<code>{docker?.error ?? 'Docker daemon is not reachable.'}</code>
<code>{docker?.error ?? $t('daemons.dockerNotReachable')}</code>
<p>{$t('daemons.dockerHint')}</p>
</div>
{:else}
@@ -3,31 +3,23 @@
breakdown + top consumers. Drops into the dashboard as its own section.
-->
<script lang="ts">
import type { SystemStats, SystemStatsSample, ContainerStatsSample } from '$lib/types';
import type { SystemStats, SystemStatsSample, TopContainerSample } from '$lib/types';
import * as api from '$lib/api';
import ResourceChart from './ResourceChart.svelte';
import type { EChartsOption } from 'echarts';
import { t } from '$lib/i18n';
import { formatBytes } from '$lib/format/bytes';
import { statsInterval, ensureStatsIntervalLoaded } from '$lib/stores/statsInterval';
let current = $state<SystemStats | null>(null);
let history = $state<SystemStatsSample[]>([]);
let top = $state<ContainerStatsSample[]>([]);
let top = $state<TopContainerSample[]>([]);
let topBy = $state<'cpu' | 'memory'>('cpu');
let window = $state<'30m' | '2h' | '6h' | '24h'>('2h');
let historyWindow = $state<'30m' | '2h' | '6h' | '24h'>('2h');
let dockerDown = $state(false);
let otherError = $state('');
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
const kb = bytes / 1024;
if (kb < 1024) return `${kb.toFixed(0)} KB`;
const mb = kb / 1024;
if (mb < 1024) return `${mb.toFixed(1)} MB`;
const gb = mb / 1024;
if (gb < 1024) return `${gb.toFixed(2)} GB`;
const tb = gb / 1024;
return `${tb.toFixed(2)} TB`;
}
ensureStatsIntervalLoaded();
async function load(signal?: AbortSignal) {
// Each request is handled independently so a 503 on `current` does
@@ -35,7 +27,7 @@
// which is available even when Docker is down).
const [currRes, histRes, topRes] = await Promise.allSettled([
api.fetchSystemStats(signal),
api.fetchSystemStatsHistory(window, signal),
api.fetchSystemStatsHistory(historyWindow, signal),
api.fetchTopContainers(topBy, 5, signal)
]);
@@ -75,15 +67,15 @@
}
$effect(() => {
// Read window/topBy so this effect re-runs when they change.
void window;
// Read historyWindow/topBy so this effect re-runs when they change.
void historyWindow;
void topBy;
const controller = new AbortController();
load(controller.signal);
const t = setInterval(() => load(controller.signal), 15_000);
const intervalId = setInterval(() => load(controller.signal), 15_000);
return () => {
controller.abort();
clearInterval(t);
clearInterval(intervalId);
};
});
@@ -180,7 +172,7 @@
<div class="mb-2 flex items-center justify-between gap-2">
<span class="text-xs font-medium text-[var(--text-secondary)]">{$t('resources.workloadUtilization')}</span>
<select
bind:value={window}
bind:value={historyWindow}
class="rounded border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-0.5 text-xs text-[var(--text-secondary)] focus:outline-none"
>
<option value="30m">{$t('resources.windowMinutes', { n: '30' })}</option>
@@ -191,7 +183,9 @@
</div>
{#if history.length === 0}
<p class="py-6 text-center text-xs text-[var(--text-tertiary)]">
{$t('resources.noSamples', { interval: '15' })}
{$statsInterval > 0
? $t('resources.noSamples', { interval: String($statsInterval) })
: $t('resources.collectionDisabled')}
</p>
{:else}
<ResourceChart option={chartOption} height="180px" ariaLabel={$t('resources.workloadUtilization')} />
@@ -253,8 +247,8 @@
<span class="rounded bg-[var(--surface-card-hover)] px-1.5 py-0.5 text-[10px] text-[var(--text-tertiary)]">
{s.owner_type === 'site' ? $t('resources.site') : $t('resources.instance')}
</span>
<span class="ml-2 font-mono text-[10px] text-[var(--text-tertiary)]">
{s.container_id.slice(0, 12)}
<span class="ml-2 truncate text-[var(--text-primary)]">
{s.owner_name || (s.container_id ? s.container_id.slice(0, 12) : '')}
</span>
</span>
<span class="tabular-nums text-[var(--text-primary)]">
+16
View File
@@ -0,0 +1,16 @@
/**
* Formats a byte count using the closest binary unit (B / KB / MB / GB / TB).
* Returns "0 B" for zero or negative inputs so the UI never renders "NaN".
*/
export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
if (bytes < 1024) return `${bytes} B`;
const kb = bytes / 1024;
if (kb < 1024) return `${kb.toFixed(0)} KB`;
const mb = kb / 1024;
if (mb < 1024) return `${mb.toFixed(1)} MB`;
const gb = mb / 1024;
if (gb < 1024) return `${gb.toFixed(2)} GB`;
const tb = gb / 1024;
return `${tb.toFixed(2)} TB`;
}
+9 -1
View File
@@ -3,6 +3,9 @@
"name": "Tinyforge",
"version": "v0.1"
},
"layout": {
"serviceStatus": "Service status"
},
"health": {
"connected": "connected",
"disconnected": "disconnected",
@@ -56,6 +59,7 @@
"windowMinutes": "{n} minutes",
"windowHours": "{n} hours",
"noSamples": "No samples yet — the collector samples every {interval}s.",
"collectionDisabled": "Stats collection is disabled. Enable it in Settings to populate this chart.",
"diskImages": "Images",
"diskContainers": "Containers",
"diskVolumes": "Volumes",
@@ -949,7 +953,11 @@
"dockerHint": "Check that the Docker daemon is running and that the socket is reachable.",
"proxyHint": "Verify the proxy URL, credentials, and that the service is listening.",
"noProxyDesc": "No proxy provider is configured. Tinyforge can manage routes via Nginx Proxy Manager or Traefik.",
"configureProxy": "Configure in Settings"
"configureProxy": "Configure in Settings",
"dockerNotReachable": "Docker daemon is not reachable.",
"dockerUnreachable": "Docker unreachable",
"proxyUnreachable": "Proxy unreachable",
"reachable": "reachable"
},
"dns": {
"title": "DNS Records",
+9 -1
View File
@@ -3,6 +3,9 @@
"name": "Tinyforge",
"version": "v0.1"
},
"layout": {
"serviceStatus": "Состояние служб"
},
"health": {
"connected": "подключён",
"disconnected": "отключён",
@@ -56,6 +59,7 @@
"windowMinutes": "{n} минут",
"windowHours": "{n} часов",
"noSamples": "Пока нет данных — сбор идёт каждые {interval}с.",
"collectionDisabled": "Сбор статистики отключён. Включите его в Настройках, чтобы заполнить график.",
"diskImages": "Образы",
"diskContainers": "Контейнеры",
"diskVolumes": "Тома",
@@ -949,7 +953,11 @@
"dockerHint": "Проверьте, что Docker-демон запущен и сокет доступен.",
"proxyHint": "Проверьте URL прокси, учётные данные и доступность сервиса.",
"noProxyDesc": "Провайдер прокси не настроен. Tinyforge поддерживает Nginx Proxy Manager или Traefik.",
"configureProxy": "Настроить в параметрах"
"configureProxy": "Настроить в параметрах",
"dockerNotReachable": "Docker-демон недоступен.",
"dockerUnreachable": "Docker недоступен",
"proxyUnreachable": "Прокси недоступен",
"reachable": "доступен"
},
"dns": {
"title": "DNS-записи",
+27
View File
@@ -0,0 +1,27 @@
import { writable } from 'svelte/store';
import { getSettings } from '$lib/api';
/**
* Reactive view of the configured stats collection interval (seconds).
* Set to 0 when collection is disabled. Refreshed on mount and after the
* settings page saves. Components can subscribe with `$statsInterval` to
* render contextual messages ("samples every Ns", "collection disabled").
*/
export const statsInterval = writable<number>(15);
let loaded = false;
export async function refreshStatsInterval(): Promise<void> {
try {
const s = await getSettings();
const v = s?.stats_interval_seconds;
statsInterval.set(typeof v === 'number' ? v : 15);
loaded = true;
} catch {
// Leave the previous value if the request fails.
}
}
export function ensureStatsIntervalLoaded(): void {
if (!loaded) void refreshStatsInterval();
}
+11
View File
@@ -120,6 +120,8 @@ export interface Settings {
wildcard_dns: boolean;
dns_provider: string;
has_cloudflare_api_token: boolean;
/** Sent on PUT to update the Cloudflare API token; never returned by GET. */
cloudflare_api_token?: string;
cloudflare_zone_id: string;
image_prune_threshold_mb: number;
proxy_provider: string;
@@ -492,6 +494,15 @@ export interface ContainerStatsSample {
block_write_bytes: number;
}
/**
* A container sample augmented with the human-readable owner name returned
* by the /system/stats/top endpoint. Container ID is empty for non-admin
* viewers to avoid leaking workload identifiers across access boundaries.
*/
export interface TopContainerSample extends ContainerStatsSample {
owner_name: string;
}
/** Host-level snapshot returned by /api/system/stats. */
export interface SystemStats {
timestamp: string;