diff --git a/docker/web.Dockerfile b/docker/web.Dockerfile index 037e3f4..e4c27ec 100644 --- a/docker/web.Dockerfile +++ b/docker/web.Dockerfile @@ -82,6 +82,23 @@ COPY --from=build --chown=afis:afis /repo/web/public ./web/public # Migrations-CLI samt Schema, Migrationsordner und Engine. COPY --from=build --chown=afis:afis /migrator ./migrator +# Die Dateiablage **muss im Image liegen**, sonst kann niemand hochladen. +# +# `files` ist ein benanntes Volume und wird auf /data/files gelegt. Existiert +# dieser Pfad im Image nicht, legt Docker ihn beim Einhaengen selbst an - als +# root mit 755. Der Dienst laeuft aber als afis (1001) und bekommt beim ersten +# Schreibversuch EACCES: jeder Upload scheiterte mit einer nichtssagenden +# Meldung, vom Lichtbild bis zum Anhang. +# +# Liegt das Verzeichnis dagegen im Image und ist leer, uebernimmt Docker es +# samt Eigentuemer in ein leeres Volume. Nachgemessen: ein bereits befuelltes +# Volume behaelt seine alten Rechte, ein leeres nicht. +# +# quarantine/ und store/ gleich mit anlegen - dieselben zwei Verzeichnisse, die +# lib/files/paths.ts erwartet. +RUN mkdir -p /data/files/quarantine /data/files/store && \ + chown -R afis:afis /data + USER afis EXPOSE 3000 diff --git a/web/lib/health/check.ts b/web/lib/health/check.ts index e5c8276..9edd34e 100644 --- a/web/lib/health/check.ts +++ b/web/lib/health/check.ts @@ -1,13 +1,17 @@ +import { constants } from 'node:fs' +import { access, mkdir } from 'node:fs/promises' +import path from 'node:path' import { db } from '@/lib/db' import { getEnv } from '@/lib/env' import { pingClamav } from '@/lib/files/clamav' +import { QUARANTINE, STORE } from '@/lib/files/paths' export type ComponentState = 'up' | 'degraded' | 'down' export type HealthReport = { status: ComponentState checkedAt: string - components: Record<'database' | 'discordGateway' | 'clamav', { + components: Record<'database' | 'discordGateway' | 'clamav' | 'fileStore', { status: ComponentState detail?: string }> @@ -49,29 +53,65 @@ async function checkClamav(): Promise<{ status: ComponentState; detail?: string } /** - * Sammelt den Zustand der drei Abhaengigkeiten. Die Gesamtbewertung faellt + * Ist die Dateiablage beschreibbar? + * + * Steht hier, weil genau das einmal unbemerkt kaputt war: das Volume wurde auf + * einen Pfad gelegt, den es im Image nicht gab, Docker legte ihn als root an, + * und der Dienst laeuft als afis. Jeder Upload scheiterte, und zu sehen war + * davon nichts - bis jemand eine Datei anhaengen wollte. Ein Zustand, den man + * nur durch Ausprobieren erfaehrt, gehoert in die Statusanzeige. + * + * Geprueft wird das Recht, nicht durch eine Probedatei: ein Healthcheck, der + * im Sekundentakt schreibt und aufraeumt, ist selbst ein Risiko. + */ +async function checkFileStore(): Promise<{ status: ComponentState; detail?: string }> { + const wurzel = path.resolve(getEnv().FILES_DIR) + try { + for (const bereich of [QUARANTINE, STORE]) { + const verzeichnis = path.join(wurzel, bereich) + // Anlegen, falls es fehlt - das ist der Normalfall bei einer frischen + // Ablage und kein Fehler. + await mkdir(verzeichnis, { recursive: true }) + await access(verzeichnis, constants.W_OK | constants.X_OK) + } + return { status: 'up' } + } catch (error) { + const grund = error instanceof Error && 'code' in error ? String(error.code) : 'unbekannt' + return { + status: 'down', + detail: + grund === 'EACCES' + ? `${wurzel} ist fuer den Dienst nicht beschreibbar` + : `${wurzel}: ${grund}`, + } + } +} + +/** + * Sammelt den Zustand der Abhaengigkeiten. Die Gesamtbewertung faellt * bewusst nur auf `down`, wenn die Datenbank weg ist: Worker, clamav und pdf * haben eigene Healthchecks im Stack, und ein ausgefallener Virenscanner soll * nicht den ganzen Web-Container als tot markieren. Sichtbar bleibt der Ausfall * trotzdem, in dieser Antwort und in Dockge am jeweiligen Dienst. */ export async function collectHealth(): Promise { - const [database, discordGateway, clamav] = await Promise.all([ + const [database, discordGateway, clamav, fileStore] = await Promise.all([ checkDatabase(), checkDiscordGateway(), checkClamav(), + checkFileStore(), ]) const status: ComponentState = database.status === 'down' ? 'down' - : discordGateway.status === 'up' && clamav.status === 'up' + : discordGateway.status === 'up' && clamav.status === 'up' && fileStore.status === 'up' ? 'up' : 'degraded' return { status, checkedAt: new Date().toISOString(), - components: { database, discordGateway, clamav }, + components: { database, discordGateway, clamav, fileStore }, } } diff --git a/web/lib/i18n/shell.ts b/web/lib/i18n/shell.ts index 82748ea..0aa7c25 100644 --- a/web/lib/i18n/shell.ts +++ b/web/lib/i18n/shell.ts @@ -109,6 +109,7 @@ export const healthText = { database: 'Datenbank', discordGateway: 'Discord-Gateway', clamav: 'Virenprüfung', + fileStore: 'Dateiablage', } as Record, } as const diff --git a/web/tests/health.test.ts b/web/tests/health.test.ts index 7f0c2d6..4248587 100644 --- a/web/tests/health.test.ts +++ b/web/tests/health.test.ts @@ -1,4 +1,15 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { chmod, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' + +/** + * Die Statusanzeige ist die einzige Stelle, an der ein stiller Ausfall + * auffaellt, bevor jemand darueber stolpert. Beide Faelle, die es hier je + * wirklich gab, stehen deshalb als Pruefung da: ein Virenscanner, der nicht + * antwortet, und eine Dateiablage, in die der Dienst nicht schreiben darf. + */ +let ablage = '' vi.mock('@/lib/db', () => ({ db: { @@ -8,19 +19,65 @@ vi.mock('@/lib/db', () => ({ })) vi.mock('@/lib/env', () => ({ - getEnv: () => ({ CLAMAV_ADDRESS: 'clamav:3310' }), + getEnv: () => ({ CLAMAV_ADDRESS: 'clamav:3310', FILES_DIR: ablage }), })) vi.mock('@/lib/files/clamav', () => ({ pingClamav: vi.fn(async () => true) })) describe('collectHealth', () => { - it('meldet betriebsbereit, wenn alle drei Abhaengigkeiten antworten', async () => { + beforeAll(async () => { + ablage = await mkdtemp(path.join(tmpdir(), 'afis-ablage-')) + }) + + afterAll(async () => { + await chmod(ablage, 0o755).catch(() => undefined) + await rm(ablage, { recursive: true, force: true }) + }) + + it('meldet betriebsbereit, wenn alle Abhaengigkeiten antworten', async () => { const { collectHealth } = await import('@/lib/health/check') const report = await collectHealth() expect(report.status).toBe('up') expect(report.components.database.status).toBe('up') expect(report.components.discordGateway.status).toBe('up') expect(report.components.clamav.status).toBe('up') + expect(report.components.fileStore.status).toBe('up') + }) + + it('legt die beiden Verzeichnisse an, wenn sie fehlen', async () => { + // Eine frische Ablage ist leer. Das ist kein Fehler, sondern der + // Normalfall beim ersten Start. + const { collectHealth } = await import('@/lib/health/check') + const report = await collectHealth() + expect(report.components.fileStore.status).toBe('up') + }) + + it('meldet eine Ablage, in die der Dienst nicht schreiben darf', async () => { + // Genau der Fehler, der jeden Upload scheitern liess: das Volume gehoerte + // root, der Dienst laeuft als afis. Zu sehen war davon nichts. + // + // Nachgestellt wird ein **leeres**, gesperrtes Verzeichnis - so lag es + // auch im Container. Ein Verzeichnis, in dem quarantine/ und store/ schon + // stehen und beschreibbar sind, ist der Fall nicht: dann arbeitet der + // Dienst weiter, und die Anzeige soll ihn auch nicht anschwaerzen. + const vorher = ablage + ablage = await mkdtemp(path.join(tmpdir(), 'afis-gesperrt-')) + await chmod(ablage, 0o555) + + try { + const { collectHealth } = await import('@/lib/health/check') + const report = await collectHealth() + + expect(report.components.fileStore.status).toBe('down') + expect(report.components.fileStore.detail).toContain('nicht beschreibbar') + // Die Datenbank steht, also faellt nicht alles aus - aber betriebsbereit + // ist es auch nicht. + expect(report.status).toBe('degraded') + } finally { + await chmod(ablage, 0o755) + await rm(ablage, { recursive: true, force: true }) + ablage = vorher + } }) it('bleibt nur eingeschraenkt, wenn allein die Virenpruefung ausfaellt', async () => {