refactor progress

This commit is contained in:
Hampus Kraft
2026-02-17 12:22:36 +00:00
parent cb31608523
commit d5abd1a7e4
8257 changed files with 1190207 additions and 761040 deletions

View File

@@ -0,0 +1,25 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {VirusScanProviderResult} from '@fluxer/virus_scan/src/VirusScanProviderResult';
export interface IVirusScanProvider {
scanFile(filePath: string): Promise<VirusScanProviderResult>;
scanBuffer(buffer: Buffer): Promise<VirusScanProviderResult>;
}

View File

@@ -0,0 +1,26 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {VirusScanResult} from '@fluxer/virus_scan/src/VirusScanResult';
export interface IVirusScanService {
initialize(): Promise<void>;
scanFile(filePath: string): Promise<VirusScanResult>;
scanBuffer(buffer: Buffer, filename: string): Promise<VirusScanResult>;
}

View File

@@ -0,0 +1,23 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
export interface VirusScanProviderResult {
isClean: boolean;
threat?: string;
}

View File

@@ -0,0 +1,24 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
export interface VirusScanResult {
isClean: boolean;
threat?: string;
fileHash: string;
}

View File

@@ -0,0 +1,147 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
import type {IVirusHashCache} from '@fluxer/virus_scan/src/cache/IVirusHashCache';
import type {IVirusScanFailureReporter} from '@fluxer/virus_scan/src/failures/IVirusScanFailureReporter';
import {NoopVirusScanFailureReporter} from '@fluxer/virus_scan/src/failures/NoopVirusScanFailureReporter';
import type {IVirusScanProvider} from '@fluxer/virus_scan/src/IVirusScanProvider';
import type {IVirusScanService} from '@fluxer/virus_scan/src/IVirusScanService';
import type {VirusScanResult} from '@fluxer/virus_scan/src/VirusScanResult';
export interface VirusScanConfig {
failOpen: boolean;
cachedThreatLabel?: string;
}
export interface VirusScanServiceDependencies {
provider: IVirusScanProvider;
virusHashCache: IVirusHashCache;
logger: LoggerInterface;
config: VirusScanConfig;
failureReporter?: IVirusScanFailureReporter;
}
export class VirusScanService implements IVirusScanService {
private readonly cachedThreatLabel: string;
private readonly failureReporter: IVirusScanFailureReporter;
constructor(private dependencies: VirusScanServiceDependencies) {
this.cachedThreatLabel = dependencies.config.cachedThreatLabel ?? 'Cached virus signature';
this.failureReporter = dependencies.failureReporter ?? new NoopVirusScanFailureReporter();
}
async initialize(): Promise<void> {
await this.failureReporter.initialize();
}
async scanFile(filePath: string): Promise<VirusScanResult> {
const buffer = await fs.readFile(filePath);
return this.scanBuffer(buffer, path.basename(filePath));
}
async scanBuffer(buffer: Buffer, filename: string): Promise<VirusScanResult> {
const fileHash = this.createFileHash(buffer);
const isCachedVirus = await this.dependencies.virusHashCache.isKnownVirusHash(fileHash);
if (isCachedVirus) {
return {
isClean: false,
threat: this.cachedThreatLabel,
fileHash,
};
}
try {
const scanResult = await this.dependencies.provider.scanBuffer(buffer);
if (scanResult.isClean) {
return {
isClean: true,
fileHash,
};
}
if (!scanResult.threat) {
throw new Error('Virus scan provider returned infected status without threat name');
}
await this.dependencies.virusHashCache.cacheVirusHash(fileHash);
return {
isClean: false,
threat: scanResult.threat,
fileHash,
};
} catch (error) {
this.dependencies.logger.error(
{
error: this.describeError(error),
filename,
fileHash,
},
'Virus scan failed',
);
await this.reportScanFailure(error, filename, fileHash);
if (this.dependencies.config.failOpen) {
return {
isClean: true,
fileHash,
};
}
throw new Error(`Virus scan failed: ${this.describeError(error)}`);
}
}
private createFileHash(buffer: Buffer): string {
return crypto.createHash('sha256').update(buffer).digest('hex');
}
private describeError(error: unknown): string {
if (typeof error === 'string') {
return error;
}
if (error instanceof Error) {
return error.message;
}
return 'Unknown error';
}
private async reportScanFailure(error: unknown, filename: string, fileHash: string): Promise<void> {
try {
await this.failureReporter.reportFailure({
error,
filename,
fileHash,
failOpen: this.dependencies.config.failOpen,
});
} catch (reportError) {
this.dependencies.logger.warn(
{
error: this.describeError(reportError),
filename,
fileHash,
},
'Failed to report virus scan failure',
);
}
}
}

View File

@@ -0,0 +1,23 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
export interface IVirusHashCache {
isKnownVirusHash(fileHash: string): Promise<boolean>;
cacheVirusHash(fileHash: string): Promise<void>;
}

View File

@@ -0,0 +1,23 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
export interface IVirusScanCacheStore {
get<T>(key: string): Promise<T | null>;
set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;
}

View File

@@ -0,0 +1,53 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {IVirusHashCache} from '@fluxer/virus_scan/src/cache/IVirusHashCache';
import type {IVirusScanCacheStore} from '@fluxer/virus_scan/src/cache/IVirusScanCacheStore';
import {seconds} from 'itty-time';
export interface VirusHashCacheConfig {
keyPrefix?: string;
ttlSeconds?: number;
}
export class VirusHashCache implements IVirusHashCache {
private readonly keyPrefix: string;
private readonly ttlSeconds: number;
constructor(
private cacheStore: IVirusScanCacheStore,
config: VirusHashCacheConfig = {},
) {
this.keyPrefix = config.keyPrefix ?? 'virus';
this.ttlSeconds = config.ttlSeconds ?? seconds('7 days');
}
async isKnownVirusHash(fileHash: string): Promise<boolean> {
const cachedValue = await this.cacheStore.get(this.buildCacheKey(fileHash));
return cachedValue != null;
}
async cacheVirusHash(fileHash: string): Promise<void> {
await this.cacheStore.set(this.buildCacheKey(fileHash), 'true', this.ttlSeconds);
}
private buildCacheKey(fileHash: string): string {
return `${this.keyPrefix}:${fileHash}`;
}
}

View File

@@ -0,0 +1,25 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {VirusScanFailureContext} from '@fluxer/virus_scan/src/failures/VirusScanFailureContext';
export interface IVirusScanFailureReporter {
initialize(): Promise<void>;
reportFailure(context: VirusScanFailureContext): Promise<void>;
}

View File

@@ -0,0 +1,27 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {IVirusScanFailureReporter} from '@fluxer/virus_scan/src/failures/IVirusScanFailureReporter';
import type {VirusScanFailureContext} from '@fluxer/virus_scan/src/failures/VirusScanFailureContext';
export class NoopVirusScanFailureReporter implements IVirusScanFailureReporter {
async initialize(): Promise<void> {}
async reportFailure(_context: VirusScanFailureContext): Promise<void> {}
}

View File

@@ -0,0 +1,25 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
export interface VirusScanFailureContext {
error: unknown;
filename: string;
fileHash: string;
failOpen: boolean;
}

View File

@@ -0,0 +1,149 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
import type {IVirusScanFailureReporter} from '@fluxer/virus_scan/src/failures/IVirusScanFailureReporter';
import type {VirusScanFailureContext} from '@fluxer/virus_scan/src/failures/VirusScanFailureContext';
import {ms} from 'itty-time';
const DEFAULT_ALERT_SAMPLE_RATE = 0.05;
const DEFAULT_ERROR_FIELD_LIMIT = 900;
const DEFAULT_REQUEST_TIMEOUT_MS = ms('5 seconds');
export interface VirusScanWebhookFailureReporterConfig {
getWebhookUrl: () => Promise<string | undefined>;
logger: LoggerInterface;
sampleRate?: number;
errorFieldLimit?: number;
requestTimeoutMs?: number;
}
export class WebhookVirusScanFailureReporter implements IVirusScanFailureReporter {
private readonly sampleRate: number;
private readonly errorFieldLimit: number;
private readonly requestTimeoutMs: number;
private readonly timeoutSeconds: number;
constructor(private config: VirusScanWebhookFailureReporterConfig) {
this.sampleRate = config.sampleRate ?? DEFAULT_ALERT_SAMPLE_RATE;
this.errorFieldLimit = config.errorFieldLimit ?? DEFAULT_ERROR_FIELD_LIMIT;
this.requestTimeoutMs = config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
this.timeoutSeconds = Math.round(this.requestTimeoutMs / 1000);
}
async initialize(): Promise<void> {
const webhookUrl = await this.config.getWebhookUrl();
if (!webhookUrl) {
this.config.logger.warn(
'VirusScanService initialised without systemAlertsWebhookUrl configured - virus scan failure alerts will be disabled',
);
}
}
async reportFailure(context: VirusScanFailureContext): Promise<void> {
const webhookUrl = await this.config.getWebhookUrl();
if (!webhookUrl) {
return;
}
if (Math.random() >= this.sampleRate) {
return;
}
const errorDescription = this.truncateText(this.describeError(context.error), this.errorFieldLimit);
const payload = this.buildPayload(context, errorDescription);
try {
const response = await fetch(webhookUrl, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(this.requestTimeoutMs),
});
if (!response.ok) {
const responseBody = await response.text();
this.config.logger.warn(
{
statusCode: response.status,
responseBody,
},
'Failed to deliver virus scan alert',
);
}
} catch (error) {
if (error instanceof Error && error.name === 'TimeoutError') {
this.config.logger.warn(`Virus scan alert webhook timed out after ${this.timeoutSeconds}s`);
return;
}
this.config.logger.warn(
{
error: error instanceof Error ? error.message : String(error),
},
'Failed to deliver virus scan alert',
);
}
}
private buildPayload(context: VirusScanFailureContext, errorDescription: string) {
return {
username: 'Virus Scan Monitor',
content: 'A virus scan failed to complete.',
embeds: [
{
title: 'Virus scan failure detected',
description: `Unable to scan attachment ${context.filename}`,
color: 0xe53e3e,
fields: [
{
name: 'Scan mode',
value: context.failOpen ? 'Fail-open' : 'Fail-closed',
inline: true,
},
{
name: 'File hash',
value: context.fileHash,
},
{
name: 'Error',
value: errorDescription || 'Unknown error',
},
],
timestamp: new Date().toISOString(),
},
],
};
}
private describeError(error: unknown): string {
if (typeof error === 'string') {
return error;
}
if (error instanceof Error) {
return error.message;
}
return 'Unknown error';
}
private truncateText(value: string, limit: number): string {
if (value.length <= limit) {
return value;
}
return `${value.slice(0, limit - 3)}...`;
}
}

View File

@@ -0,0 +1,125 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import fs from 'node:fs/promises';
import {createConnection} from 'node:net';
import type {IVirusScanProvider} from '@fluxer/virus_scan/src/IVirusScanProvider';
import type {VirusScanProviderResult} from '@fluxer/virus_scan/src/VirusScanProviderResult';
export interface ClamAVConfig {
host: string;
port: number;
streamChunkBytes?: number;
}
export class ClamAVProvider implements IVirusScanProvider {
private readonly streamChunkBytes: number;
constructor(private config: ClamAVConfig) {
this.streamChunkBytes = config.streamChunkBytes ?? 2048;
}
async scanFile(filePath: string): Promise<VirusScanProviderResult> {
const buffer = await fs.readFile(filePath);
return this.scanBuffer(buffer);
}
async scanBuffer(buffer: Buffer): Promise<VirusScanProviderResult> {
return new Promise((resolve, reject) => {
const socket = createConnection(this.config.port, this.config.host);
let response = '';
let isResolved = false;
function cleanup() {
if (!socket.destroyed) {
socket.destroy();
}
}
function doReject(error: Error) {
if (isResolved) return;
isResolved = true;
cleanup();
reject(error);
}
function doResolve(result: VirusScanProviderResult) {
if (isResolved) return;
isResolved = true;
cleanup();
resolve(result);
}
socket.on('connect', () => {
try {
socket.write('zINSTREAM\0');
const chunkSize = this.streamChunkBytes;
let offset = 0;
while (offset < buffer.length) {
const remainingBytes = buffer.length - offset;
const bytesToSend = Math.min(chunkSize, remainingBytes);
const sizeBuffer = Buffer.alloc(4);
sizeBuffer.writeUInt32BE(bytesToSend, 0);
socket.write(sizeBuffer);
socket.write(buffer.subarray(offset, offset + bytesToSend));
offset += bytesToSend;
}
const endBuffer = Buffer.alloc(4);
endBuffer.writeUInt32BE(0, 0);
socket.write(endBuffer);
} catch (error) {
doReject(new Error(`ClamAV write failed: ${error instanceof Error ? error.message : String(error)}`));
}
});
socket.on('data', (data) => {
response += data.toString();
});
socket.on('end', () => {
const trimmedResponse = response.trim();
if (trimmedResponse.includes('FOUND')) {
const threatMatch = trimmedResponse.match(/:\s(.+)\sFOUND/);
const threat = threatMatch ? threatMatch[1] : 'Virus detected';
doResolve({
isClean: false,
threat,
});
} else if (trimmedResponse.includes('OK')) {
doResolve({
isClean: true,
});
} else {
doReject(new Error(`Unexpected ClamAV response: ${trimmedResponse}`));
}
});
socket.on('error', (error) => {
doReject(new Error(`ClamAV connection failed: ${error.message}`));
});
});
}
}

View File

@@ -0,0 +1,35 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {IVirusScanProvider} from '@fluxer/virus_scan/src/IVirusScanProvider';
import type {VirusScanProviderResult} from '@fluxer/virus_scan/src/VirusScanProviderResult';
export class DisabledProvider implements IVirusScanProvider {
async scanFile(_filePath: string): Promise<VirusScanProviderResult> {
return {
isClean: true,
};
}
async scanBuffer(_buffer: Buffer): Promise<VirusScanProviderResult> {
return {
isClean: true,
};
}
}