feat(ClamAvService): Add ClamAV Manager with Docker container management capabilities.
This commit is contained in:
@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartantivirus',
|
||||
version: '1.0.4',
|
||||
version: '1.1.0',
|
||||
description: 'A Node.js package for integrating antivirus scanning capabilities using ClamAV, allowing in-memory file and data scanning.'
|
||||
}
|
||||
|
274
ts/classes.clamav.manager.ts
Normal file
274
ts/classes.clamav.manager.ts
Normal file
@ -0,0 +1,274 @@
|
||||
import { exec, spawn, net, promisify, EventEmitter, execAsync } from './plugins.js';
|
||||
|
||||
export interface ClamAVLogEvent {
|
||||
timestamp: string;
|
||||
message: string;
|
||||
type: 'update' | 'scan' | 'system' | 'error';
|
||||
}
|
||||
|
||||
export class ClamAVManager extends EventEmitter {
|
||||
private containerId: string | null = null;
|
||||
private containerName = 'clamav-daemon';
|
||||
private imageTag = 'clamav/clamav:latest';
|
||||
private port = 3310;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the ClamAV container if it's not already running
|
||||
*/
|
||||
public async startContainer(): Promise<void> {
|
||||
try {
|
||||
console.log('[ClamAV] Starting container initialization...');
|
||||
|
||||
// Check if container is already running
|
||||
const { stdout: psOutput } = await execAsync('docker ps --filter name=' + this.containerName);
|
||||
if (psOutput.includes(this.containerName)) {
|
||||
console.log('[ClamAV] Container is already running');
|
||||
this.containerId = (await execAsync(`docker ps -q --filter name=${this.containerName}`)).stdout.trim();
|
||||
console.log('[ClamAV] Container ID:', this.containerId);
|
||||
this.attachLogWatcher();
|
||||
await this.waitForInitialization();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if container exists but is stopped
|
||||
const { stdout: psaOutput } = await execAsync('docker ps -a --filter name=' + this.containerName);
|
||||
if (psaOutput.includes(this.containerName)) {
|
||||
console.log('[ClamAV] Found stopped container, starting it...');
|
||||
await execAsync(`docker start ${this.containerName}`);
|
||||
this.containerId = (await execAsync(`docker ps -q --filter name=${this.containerName}`)).stdout.trim();
|
||||
console.log('[ClamAV] Started existing container, ID:', this.containerId);
|
||||
} else {
|
||||
// Create and start new container
|
||||
console.log('[ClamAV] Creating new container...');
|
||||
const { stdout } = await execAsync(
|
||||
`docker run -d --name ${this.containerName} -p ${this.port}:3310 ${this.imageTag}`
|
||||
);
|
||||
this.containerId = stdout.trim();
|
||||
console.log('[ClamAV] Created new container, ID:', this.containerId);
|
||||
}
|
||||
|
||||
this.attachLogWatcher();
|
||||
console.log('[ClamAV] Waiting for initialization...');
|
||||
await this.waitForInitialization();
|
||||
console.log('[ClamAV] Container successfully initialized');
|
||||
} catch (error) {
|
||||
console.error('[ClamAV] Error starting container:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the ClamAV container
|
||||
*/
|
||||
public async stopContainer(): Promise<void> {
|
||||
if (!this.containerId) {
|
||||
console.log('No ClamAV container is running');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await execAsync(`docker stop ${this.containerId}`);
|
||||
console.log('Stopped ClamAV container');
|
||||
} catch (error) {
|
||||
console.error('Error stopping ClamAV container:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually trigger a database update
|
||||
*/
|
||||
public async updateDatabase(): Promise<void> {
|
||||
if (!this.containerId) {
|
||||
throw new Error('ClamAV container is not running');
|
||||
}
|
||||
|
||||
try {
|
||||
// First check if freshclam is already running
|
||||
const { stdout: psOutput } = await execAsync(`docker exec ${this.containerId} ps aux | grep freshclam`);
|
||||
if (psOutput.includes('/usr/local/sbin/freshclam -d')) {
|
||||
console.log('Freshclam daemon is already running');
|
||||
// Wait a bit to ensure database is updated
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
return;
|
||||
}
|
||||
|
||||
// If not running as daemon, try to update manually
|
||||
const { stdout, stderr } = await execAsync(`docker exec ${this.containerId} freshclam --no-warnings`);
|
||||
console.log('Database update output:', stdout);
|
||||
if (stderr) {
|
||||
console.error('Database update errors:', stderr);
|
||||
}
|
||||
} catch (error) {
|
||||
// Check if the error is due to freshclam already running
|
||||
if (error.stderr?.includes('ERROR: Problem with internal logger') ||
|
||||
error.stdout?.includes('Resource temporarily unavailable')) {
|
||||
console.log('Freshclam is already running, skipping manual update');
|
||||
return;
|
||||
}
|
||||
console.error('Error updating ClamAV database:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current database version information
|
||||
*/
|
||||
public async getDatabaseInfo(): Promise<string> {
|
||||
if (!this.containerId) {
|
||||
throw new Error('ClamAV container is not running');
|
||||
}
|
||||
|
||||
try {
|
||||
// Try both .cld and .cvd files since ClamAV can use either format
|
||||
try {
|
||||
const { stdout } = await execAsync(`docker exec ${this.containerId} sigtool --info /var/lib/clamav/daily.cld`);
|
||||
return stdout;
|
||||
} catch {
|
||||
const { stdout } = await execAsync(`docker exec ${this.containerId} sigtool --info /var/lib/clamav/daily.cvd`);
|
||||
return stdout;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting database info:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch container logs and emit events for different types of log messages
|
||||
*/
|
||||
private attachLogWatcher(): void {
|
||||
if (!this.containerId) return;
|
||||
|
||||
const logProcess = spawn('docker', ['logs', '-f', this.containerId]);
|
||||
|
||||
logProcess.stdout.on('data', (data) => {
|
||||
const lines = data.toString().split('\n');
|
||||
lines.forEach(line => {
|
||||
if (!line.trim()) return;
|
||||
|
||||
const event: ClamAVLogEvent = {
|
||||
timestamp: new Date().toISOString(),
|
||||
message: line,
|
||||
type: this.determineLogType(line)
|
||||
};
|
||||
|
||||
this.emit('log', event);
|
||||
console.log(`[ClamAV ${event.type}] ${event.message}`);
|
||||
});
|
||||
});
|
||||
|
||||
logProcess.stderr.on('data', (data) => {
|
||||
const event: ClamAVLogEvent = {
|
||||
timestamp: new Date().toISOString(),
|
||||
message: data.toString(),
|
||||
type: 'error'
|
||||
};
|
||||
|
||||
this.emit('log', event);
|
||||
console.error(`[ClamAV error] ${event.message}`);
|
||||
});
|
||||
|
||||
logProcess.on('error', (error) => {
|
||||
console.error('Error in log watcher:', error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the type of log message
|
||||
*/
|
||||
private determineLogType(logMessage: string): ClamAVLogEvent['type'] {
|
||||
const lowerMessage = logMessage.toLowerCase();
|
||||
if (lowerMessage.includes('update') || lowerMessage.includes('freshclam')) {
|
||||
return 'update';
|
||||
} else if (lowerMessage.includes('scan') || lowerMessage.includes('found')) {
|
||||
return 'scan';
|
||||
} else if (lowerMessage.includes('error') || lowerMessage.includes('warning')) {
|
||||
return 'error';
|
||||
}
|
||||
return 'system';
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for ClamAV to initialize by checking both logs and service readiness
|
||||
*/
|
||||
private async waitForInitialization(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.containerId) {
|
||||
reject(new Error('Container ID not set'));
|
||||
return;
|
||||
}
|
||||
|
||||
let timeout: NodeJS.Timeout;
|
||||
let checkCount = 0;
|
||||
const maxChecks = 60; // Check for 60 seconds
|
||||
const startTime = Date.now();
|
||||
|
||||
// Check service readiness
|
||||
const checkService = async () => {
|
||||
try {
|
||||
const elapsedTime = Math.round((Date.now() - startTime) / 1000);
|
||||
console.log(`[ClamAV] Checking service readiness (attempt ${checkCount + 1}, ${elapsedTime}s elapsed)...`);
|
||||
|
||||
// First check if the service is accepting connections
|
||||
const client = new net.Socket();
|
||||
await new Promise<void>((resolveConn, rejectConn) => {
|
||||
const connectTimeout = setTimeout(() => {
|
||||
client.destroy();
|
||||
rejectConn(new Error('Connection timeout'));
|
||||
}, 1000);
|
||||
|
||||
client.connect(this.port, 'localhost', () => {
|
||||
clearTimeout(connectTimeout);
|
||||
client.end();
|
||||
resolveConn();
|
||||
});
|
||||
|
||||
client.on('error', (err) => {
|
||||
clearTimeout(connectTimeout);
|
||||
rejectConn(err);
|
||||
});
|
||||
});
|
||||
|
||||
// Verify the service is responding to commands
|
||||
const { stdout } = await execAsync(`echo PING | nc localhost ${this.port}`);
|
||||
if (!stdout.includes('PONG')) {
|
||||
throw new Error('Service not responding to commands');
|
||||
}
|
||||
|
||||
// If we can connect and get a PONG, the service is ready
|
||||
console.log('[ClamAV] Service is accepting connections and responding to commands');
|
||||
cleanup();
|
||||
resolve();
|
||||
} catch (error) {
|
||||
// Service not ready yet, will retry
|
||||
if (checkCount >= maxChecks) {
|
||||
cleanup();
|
||||
reject(new Error(`ClamAV initialization timed out after ${maxChecks} seconds. Last error: ${error.message}`));
|
||||
return;
|
||||
}
|
||||
checkCount++;
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
clearInterval(serviceCheck);
|
||||
};
|
||||
|
||||
const serviceCheck = setInterval(checkService, 1000);
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error('ClamAV initialization timed out after 60 seconds'));
|
||||
}, 60000);
|
||||
|
||||
// Start initial service check
|
||||
checkService();
|
||||
});
|
||||
}
|
||||
}
|
@ -1,25 +1,35 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import * as paths from './paths.js';
|
||||
|
||||
import { exec } from 'child_process';
|
||||
import net from 'net';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
import { net } from './plugins.js';
|
||||
import { ClamAVManager } from './classes.clamav.manager.js';
|
||||
|
||||
export class ClamAvService {
|
||||
private host: string;
|
||||
private port: number;
|
||||
private manager: ClamAVManager;
|
||||
|
||||
constructor(host: string = '127.0.0.1', port: number = 3310) {
|
||||
this.host = host;
|
||||
this.port = port;
|
||||
this.manager = new ClamAVManager();
|
||||
|
||||
// Listen to ClamAV logs
|
||||
this.manager.on('log', (event) => {
|
||||
if (event.type === 'scan') {
|
||||
console.log(`[ClamAV Scan] ${event.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async ensureContainerStarted(): Promise<void> {
|
||||
await this.manager.startContainer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans an in-memory Buffer using ClamAV daemon's INSTREAM command.
|
||||
*/
|
||||
public async scanBuffer(buffer: Buffer): Promise<{ isInfected: boolean; reason?: string }> {
|
||||
await this.ensureContainerStarted();
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = new net.Socket();
|
||||
|
||||
@ -85,6 +95,7 @@ export class ClamAvService {
|
||||
* Verifies the ClamAV daemon is reachable.
|
||||
*/
|
||||
public async verifyConnection(): Promise<boolean> {
|
||||
await this.ensureContainerStarted();
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = new net.Socket();
|
||||
|
||||
|
@ -1 +1,2 @@
|
||||
export * from './classes.smartantivirus.js';
|
||||
export * from './classes.smartantivirus.js';
|
||||
export * from './classes.clamav.manager.js';
|
@ -1,24 +1,39 @@
|
||||
// node native scope
|
||||
// Node.js built-in modules
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { exec, spawn } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { EventEmitter } from 'events';
|
||||
import net from 'net';
|
||||
|
||||
export {
|
||||
fs,
|
||||
path,
|
||||
}
|
||||
exec,
|
||||
spawn,
|
||||
promisify,
|
||||
EventEmitter,
|
||||
net
|
||||
};
|
||||
|
||||
// @push.rocks scope
|
||||
import * as smartpath from '@push.rocks/smartpath';
|
||||
import * as smartfile from '@push.rocks/smartfile';
|
||||
import { expect, tap } from '@push.rocks/tapbundle';
|
||||
|
||||
export {
|
||||
smartpath,
|
||||
smartfile,
|
||||
}
|
||||
expect,
|
||||
tap
|
||||
};
|
||||
|
||||
// third party scope
|
||||
// Third party scope
|
||||
import axios from 'axios';
|
||||
|
||||
export {
|
||||
axios,
|
||||
}
|
||||
axios
|
||||
};
|
||||
|
||||
// Common utilities
|
||||
export const execAsync = promisify(exec);
|
Reference in New Issue
Block a user