diff --git a/.env.example b/.env.example index 4107b30..2372e90 100644 --- a/.env.example +++ b/.env.example @@ -52,7 +52,10 @@ BOOTSTRAP_ADMIN_IDS= # --- Dateien und Prüfkette ---------------------------------------------------- FILES_DIR=/data/files -CLAMAV_SOCKET=/var/run/clamav/clamd.sock +# clamd: host:port im Stack, oder ein Pfad, wenn ein Unix-Socket erreichbar ist. +# Das offizielle Image legt seinen Socket unter /tmp im eigenen Container ab, +# deshalb TCP - der Port ist nur im Stack-Netz erreichbar, nicht nach aussen. +CLAMAV_ADDRESS=clamav:3310 PDF_SERVICE_URL=http://pdf:3000 # --- Cloudflare Tunnel -------------------------------------------------------- diff --git a/compose.yaml b/compose.yaml index 6732353..0065e77 100644 --- a/compose.yaml +++ b/compose.yaml @@ -45,7 +45,6 @@ services: HOSTNAME: '0.0.0.0' volumes: - files:/data/files - - clamav_socket:/var/run/clamav # Links der Port auf dem Host, rechts der im Container. Nur der linke ist # frei waehlbar - ueber WEB_PORT in der .env, damit fuer eine andere # Portwahl keine verfolgte Datei angefasst werden muss. @@ -131,10 +130,16 @@ services: environment: CLAMAV_NO_MILTERD: 'true' FRESHCLAM_CHECKS: '2' + # Nur die Signaturen. Der Scanner bekommt die Daten ueber INSTREAM durch + # die Verbindung gereicht und braucht die Dateiablage deshalb nicht zu + # sehen - siehe lib/files/clamav.ts. Ein Socket-Volume gibt es nicht mehr: + # das offizielle Image legt seinen Unix-Socket unter /tmp im eigenen + # Container an, das geteilte Verzeichnis blieb leer, und die Virenpruefung + # meldete sich dauerhaft als gestoert. Die Anbindung laeuft ueber + # TCPSocket 3310, den dasselbe Image ab Werk oeffnet. Der Port ist nicht + # veroeffentlicht und nur im Stack-Netz erreichbar. volumes: - clamav_db:/var/lib/clamav - - clamav_socket:/var/run/clamav - - files:/data/files healthcheck: test: ['CMD-SHELL', 'clamdscan --ping 1 || exit 1'] interval: 60s @@ -245,4 +250,3 @@ volumes: pgdata: files: clamav_db: - clamav_socket: diff --git a/start.sh b/start.sh index 4ca91ce..8f8ce55 100755 --- a/start.sh +++ b/start.sh @@ -99,9 +99,16 @@ info "Dateiablage unter ${FILES_DIR_LOKAL}" # Die Prüfkette braucht beides. Fehlt eines, bleiben die betroffenen Uploads in # der Quarantäne - sie gehen nicht ungeprüft durch. Siehe docs/07. -CLAMAV_SOCKET_LOKAL="$(grep -E '^CLAMAV_SOCKET=' .env 2>/dev/null | cut -d= -f2- || true)" -[ -S "${CLAMAV_SOCKET_LOKAL:-/var/run/clamav/clamd.sock}" ] \ - || warn "Kein clamd erreichbar: Uploads landen auf 'Prüfung unvollständig' und brauchen die Freigabe von Hand" +CLAMAV_ADRESSE="$(grep -E '^CLAMAV_ADDRESS=' .env 2>/dev/null | cut -d= -f2- || true)" +CLAMAV_ADRESSE="${CLAMAV_ADRESSE:-clamav:3310}" +clamd_erreichbar() { + case "$CLAMAV_ADRESSE" in + /*) [ -S "$CLAMAV_ADRESSE" ] ;; + *) (exec 3<>"/dev/tcp/${CLAMAV_ADRESSE%:*}/${CLAMAV_ADRESSE##*:}") 2>/dev/null ;; + esac +} +clamd_erreichbar \ + || warn "Kein clamd unter ${CLAMAV_ADRESSE}: Uploads landen auf 'Prüfung unvollständig' und brauchen die Freigabe von Hand" command -v qpdf >/dev/null \ || warn "qpdf fehlt: PDF-Uploads landen auf 'Prüfung unvollständig' (im Container ist es enthalten)" diff --git a/web/lib/env.ts b/web/lib/env.ts index 23a7dd7..bc0c9c5 100644 --- a/web/lib/env.ts +++ b/web/lib/env.ts @@ -27,7 +27,8 @@ const schema = z.object({ .transform((v) => v.split(',').map((s) => s.trim()).filter(Boolean)), FILES_DIR: z.string().default('/data/files'), - CLAMAV_SOCKET: z.string().default('/var/run/clamav/clamd.sock'), + /** `host:port` oder Pfad zu einem Unix-Socket. Siehe lib/files/clamav.ts. */ + CLAMAV_ADDRESS: z.string().default('clamav:3310'), PDF_SERVICE_URL: z.url().optional(), TZ: z.string().default('Europe/Berlin'), diff --git a/web/lib/files/clamav.ts b/web/lib/files/clamav.ts index 7bd7111..29d3354 100644 --- a/web/lib/files/clamav.ts +++ b/web/lib/files/clamav.ts @@ -1,14 +1,50 @@ import net from 'node:net' /** - * Anbindung an clamd über den Unix-Socket im geteilten Volume. + * Anbindung an clamd. * * Es wird **INSTREAM** benutzt, nicht SCAN mit einem Pfad: dann muss der * Scanner die Datei nicht sehen können, und die Anbindung funktioniert auch, * wenn clamd anderswo läuft. Das Protokoll ist simpel - Länge als 4 Byte, * Nutzdaten, dann eine Null-Länge als Abschluss. + * + * Die Adresse ist entweder `host:port` oder ein Pfad zu einem Unix-Socket. + * Vorgabe ist TCP, weil das offizielle clamav-Image seinen Unix-Socket unter + * `/tmp/clamd.sock` anlegt und nicht dort, wo ein geteiltes Volume ihn + * sichtbar machen könnte - ein Socket-Volume blieb schlicht leer und die + * Virenprüfung meldete sich dauerhaft als gestört. `TCPSocket 3310` ist in + * demselben Image ab Werk offen, und im Stack ist der Port nicht nach aussen + * veröffentlicht. */ +/** `host:port` oder ein absoluter Pfad. Getrennt, damit es prüfbar ist. */ +export type ClamavAddress = { path: string } | { host: string; port: number } + +export function parseClamavAddress(address: string): ClamavAddress { + const sauber = address.trim() + if (sauber.startsWith('/') || sauber.startsWith('.')) return { path: sauber } + + // Von hinten trennen, damit eine IPv6-Adresse in Klammern heil bleibt. + const trenner = sauber.lastIndexOf(':') + if (trenner <= 0) throw new Error(`clamd-Adresse ohne Port: ${address}`) + + const port = Number(sauber.slice(trenner + 1)) + if (!Number.isInteger(port) || port < 1 || port > 65_535) { + throw new Error(`clamd-Adresse mit ungültigem Port: ${address}`) + } + + const host = sauber.slice(0, trenner).replace(/^\[|\]$/g, '') + if (host.length === 0) throw new Error(`clamd-Adresse ohne Host: ${address}`) + return { host, port } +} + +function verbinden(address: string): net.Socket { + const ziel = parseClamavAddress(address) + return 'path' in ziel + ? net.createConnection(ziel.path) + : net.createConnection({ host: ziel.host, port: ziel.port }) +} + export type ScanResult = | { ok: true; clean: true } | { ok: true; clean: false; signature: string } @@ -18,7 +54,7 @@ export type ScanResult = const CHUNK = 64 * 1024 export function scanBuffer( - socketPath: string, + address: string, content: Uint8Array, timeoutMs = 30_000, ): Promise { @@ -26,7 +62,7 @@ export function scanBuffer( let settled = false let antwort = '' - const socket = net.createConnection(socketPath) + const socket = verbinden(address) const done = (result: ScanResult) => { if (settled) return settled = true @@ -69,11 +105,8 @@ export function scanBuffer( }) } -/** - * Fragt clamd über den Unix-Socket. Der Socket liegt im geteilten Volume - * clamav_socket, siehe compose.yaml. Antwort auf zPING ist "PONG". - */ -export function pingClamav(socketPath: string, timeoutMs = 2000): Promise { +/** Fragt clamd, ob er da ist. Antwort auf zPING ist "PONG". */ +export function pingClamav(address: string, timeoutMs = 2000): Promise { return new Promise((resolve) => { let settled = false const done = (ok: boolean) => { @@ -83,7 +116,7 @@ export function pingClamav(socketPath: string, timeoutMs = 2000): Promise socket.write('zPING\0')) socket.on('data', (chunk) => done(chunk.toString().includes('PONG'))) diff --git a/web/lib/files/process.ts b/web/lib/files/process.ts index d2822f6..cc0ec21 100644 --- a/web/lib/files/process.ts +++ b/web/lib/files/process.ts @@ -66,7 +66,7 @@ export async function runCheckChain( // 2. Virenscanner. if (!options.skipScan) { - const scan = await scanBuffer(getEnv().CLAMAV_SOCKET, inhalt) + const scan = await scanBuffer(getEnv().CLAMAV_ADDRESS, inhalt) if (!scan.ok) { // Nicht blockiert, sondern unentschieden: die Datei ist nicht als böse // erkannt, sie ist ungeprüft. Ein Administrator kann sie freigeben. diff --git a/web/lib/health/check.ts b/web/lib/health/check.ts index 51c0709..e5c8276 100644 --- a/web/lib/health/check.ts +++ b/web/lib/health/check.ts @@ -44,7 +44,7 @@ async function checkDiscordGateway(): Promise<{ status: ComponentState; detail?: } async function checkClamav(): Promise<{ status: ComponentState; detail?: string }> { - const ok = await pingClamav(getEnv().CLAMAV_SOCKET) + const ok = await pingClamav(getEnv().CLAMAV_ADDRESS) return ok ? { status: 'up' } : { status: 'down', detail: 'clamd antwortet nicht' } } diff --git a/web/tests/health.test.ts b/web/tests/health.test.ts index cdedd39..7f0c2d6 100644 --- a/web/tests/health.test.ts +++ b/web/tests/health.test.ts @@ -8,7 +8,7 @@ vi.mock('@/lib/db', () => ({ })) vi.mock('@/lib/env', () => ({ - getEnv: () => ({ CLAMAV_SOCKET: '/var/run/clamav/clamd.sock' }), + getEnv: () => ({ CLAMAV_ADDRESS: 'clamav:3310' }), })) vi.mock('@/lib/files/clamav', () => ({ pingClamav: vi.fn(async () => true) })) diff --git a/web/tests/pruefkette.test.ts b/web/tests/pruefkette.test.ts index 4004275..8a2e52c 100644 --- a/web/tests/pruefkette.test.ts +++ b/web/tests/pruefkette.test.ts @@ -5,6 +5,7 @@ import sharp from 'sharp' import { db } from '@/lib/db' import { acceptUpload, loadDeliverable, releaseAttachment } from '@/lib/files/service' import { runCheckChain } from '@/lib/files/process' +import { parseClamavAddress, pingClamav, scanBuffer } from '@/lib/files/clamav' import { absolutePath } from '@/lib/files/paths' import { AccessError } from '@/lib/errors' import type { EffectivePermissions, Permission } from '@/lib/auth/permissions' @@ -19,6 +20,79 @@ import type { EffectivePermissions, Permission } from '@/lib/auth/permissions' const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL const suite = url ? describe : describe.skip +describe('Adresse des Virenscanners', () => { + /** + * Der Grund für diese Prüfungen: die Adresse stand als Pfad auf ein + * geteiltes Volume in der Konfiguration, das offizielle clamav-Image legt + * seinen Unix-Socket aber unter /tmp im eigenen Container an. Das + * Verzeichnis blieb leer, jeder Verbindungsversuch lief in ENOENT, und die + * Virenprüfung meldete sich dauerhaft als gestört. Ein Tippfehler in der + * Adresse darf nicht wieder still enden. + */ + it('liest host:port', () => { + expect(parseClamavAddress('clamav:3310')).toEqual({ host: 'clamav', port: 3310 }) + expect(parseClamavAddress(' 127.0.0.1:3310 ')).toEqual({ host: '127.0.0.1', port: 3310 }) + }) + + it('lässt eine IPv6-Adresse heil', () => { + expect(parseClamavAddress('[::1]:3310')).toEqual({ host: '::1', port: 3310 }) + }) + + it('erkennt einen Unix-Socket am Pfad', () => { + expect(parseClamavAddress('/tmp/clamd.sock')).toEqual({ path: '/tmp/clamd.sock' }) + }) + + it('wirft bei Unsinn, statt still ins Leere zu greifen', () => { + for (const eingabe of ['clamav', 'clamav:', ':3310', 'clamav:0', 'clamav:99999', '']) { + expect(() => parseClamavAddress(eingabe), eingabe).toThrow() + } + }) + + it('meldet einen nicht erreichbaren Scanner als nicht erreichbar', async () => { + // Port 1 ist reserviert und lauscht nirgends. Kein Absturz, ein `false`. + expect(await pingClamav('127.0.0.1:1', 1000)).toBe(false) + expect(await pingClamav('/gibt/es/nicht.sock', 1000)).toBe(false) + }) +}) + +/** + * Gegen einen laufenden Scanner. Standardmässig übersprungen, weil auf dem + * Entwicklungsrechner keiner läuft. + * + * Diese Prüfungen hätten den Fehler gefunden, den die obigen nicht finden + * konnten: eine Adresse, die auf nichts zeigt, ist syntaktisch tadellos. Nur + * eine echte Verbindung beantwortet die Frage, ob die Prüfkette wirklich hält. + * + * CLAMAV_LIVE=clamav:3310 pnpm --filter @afis/web test pruefkette + */ +const live = process.env.CLAMAV_LIVE +const liveSuite = live ? describe : describe.skip + +liveSuite('Gegen einen laufenden Scanner', () => { + it('antwortet auf die Anfrage', async () => { + expect(await pingClamav(live!, 5000)).toBe(true) + }) + + it('lässt harmlosen Inhalt durch', async () => { + expect(await scanBuffer(live!, Buffer.from('harmloser Text'))).toEqual({ + ok: true, + clean: true, + }) + }) + + it('erkennt die Testsignatur', async () => { + // EICAR ist kein Schadprogramm, sondern die vereinbarte Zeichenfolge, an + // der jeder Scanner anschlagen muss. Damit lässt sich prüfen, dass die + // Kette wirklich prüft und nicht nur eine Verbindung aufbaut. + const eicar = 'X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*' + expect(await scanBuffer(live!, Buffer.from(eicar))).toEqual({ + ok: true, + clean: false, + signature: 'Eicar-Test-Signature', + }) + }) +}) + const marke = `kette-${Date.now()}` function perms(