Verzeichnisüberwachung und Virenscans in Echtzeit mit Rust und ClamAV
Erstellen Sie ein Überwachungsprogramm in Rust, das fertig geschriebene Dateien mit einem lokalen ClamAV-Daemon scannt. Es meldet die Ergebnisse „sauber“, „infiziert“ und „Scannerfehler“, ohne Dateien zu verschieben oder zu löschen. Als sauber zählt ausschließlich eine ausdrückliche entsprechende Antwort.
Warum Echtzeitüberwachung und Rust?
Dateisystemereignisse können eintreffen, bevor ein Prozess das Schreiben beendet hat. Eine Verzögerung oder eine unveränderte Dateigröße beweist nicht, dass die Datei vollständig ist. Dieses Beispiel setzt deshalb eine Vereinbarung mit dem erzeugenden Prozess voraus: Schreiben Sie eine Datei mit eindeutigem Namen und der Endung .part, schließen Sie sie und benennen Sie sie anschließend im selben Verzeichnis atomar in .ready um. Ändern Sie niemals eine Datei mit der Endung .ready. Beschränken Sie den Zugriff auf das Verzeichnis auf die Anwendung und ihren vertrauenswürdigen Erzeuger.
Das Überwachungsprogramm scannt ausschließlich Dateien mit der Endung .ready auf der obersten Ebene. Es dient der Benachrichtigung, nicht der Zugriffskontrolle: Ein Ergebnis beschreibt die gescannten Bytes und berechtigt nicht dazu, später über einen veränderlichen Pfad zu lesen.
Voraussetzungen
Verwenden Sie Linux oder macOS mit Rust und Cargo. Das Programm wurde mit Rust 1.98.1 kompiliert und mit ClamAV 1.5.4 getestet. Ein lokaler clamd-Daemon muss über eine aktuelle Signaturdatenbank und einen Unix-Socket verfügen, auf den der Rust-Prozess zugreifen kann.
ClamAV-Daemon einrichten (clamd)
Installieren Sie unter Debian oder Ubuntu den Daemon und das Programm zur Aktualisierung der Virendefinitionen:
sudo apt-get update
sudo apt-get install clamav-daemon clamav-freshclam
Behalten Sie in der clamd.conf Ihrer Distribution die Einstellungen für Datenbank und Dienstbenutzer bei und konfigurieren Sie einen lokalen Socket. Dies ist ein Konfigurationsausschnitt; stellen Sie sicher, dass keine TCPSocket-Direktive aktiviert bleibt:
LocalSocket /var/run/clamav/clamd.ctl
LocalSocketMode 660
StreamMaxLength 10M
MaxFileSize 10M
MaxScanSize 20M
AlertExceedsMax yes
Gewähren Sie dem Dienstbenutzer der Anwendung Zugriff über die Gruppe des Sockets und starten Sie den Dienst anschließend neu. Lassen Sie den FreshClam-Dienst der Distribution die Datenbank aktuell halten. Führen Sie kein zweites Aktualisierungsprogramm manuell gegen die gesperrte Datenbank aus.
sudo systemctl enable --now clamav-freshclam
sudo systemctl restart clamav-daemon
Die ClamAV-Protokolldokumentation beschreibt den Socket und die Rahmung der INSTREAM-Nachrichten. Clamd bietet keine TCP-Authentifizierung; dieses Beispiel verwendet ausschließlich einen lokalen Socket mit Zugriffskontrolle über Berechtigungen. Konfigurieren Sie Archiv- und Scan-Grenzwerte passend zu Ihrer Arbeitslast. Meldungen über überschrittene Grenzwerte dürfen nicht als sauber gelten.
Projekt einrichten
cargo new realtime_virus_scanner --edition 2021 --vcs none
cd realtime_virus_scanner
Ersetzen Sie Cargo.toml durch:
[package]
name = "realtime_virus_scanner"
version = "0.1.0"
edition = "2021"
[dependencies]
clap = { version = "=4.5.50", features = ["derive"] }
notify = "=8.2.0"
tokio = { version = "=1.48.0", features = ["fs", "io-util", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] }
Behalten Sie Cargo.lock in Ihrer Anwendung bei. Das Beispiel implementiert den kleinen INSTREAM-Nachrichtenaustausch direkt mit Tokio, statt einen separaten ClamAV-Client-Wrapper zu nutzen.
Befehlszeilenschnittstelle mit clap
Die CLI akzeptiert --directory und --socket. Fügen Sie --once hinzu, um die vorhandenen, fertig geschriebenen Dateien zu scannen und das Programm zu beenden: Null bedeutet, dass alle ausgewählten Dateien sauber waren; eins bedeutet, dass eine Infektion oder ein Scannerfehler aufgetreten ist. Im Überwachungsmodus werden die Scans bis zu Ctrl-C wiederholt und alle Ergebnisse gemeldet.
Verzeichnisüberwachung in Echtzeit mit notify
Ein empfohlener Watcher weckt den Scanner nach einem Erstellungs-, Änderungs- oder Löschereignis. Ein Kanal mit einem Speicherplatz fasst Ereignisse zusammen, da bei jedem Aufwecken das Verzeichnis erneut gescannt wird. Ein zusätzlicher Abgleich alle fünf Sekunden findet Dateien auch dann, wenn Ereignisse verpasst wurden. Lese- und Zugriffsereignisse werden ignoriert, damit Scans sich nicht selbst auslösen.
Asynchrone Virenscans mit Tokio
Jeder Scan erstellt eine größenbegrenzte Momentaufnahme, überträgt sie in Blöcken von 64 KiB und liest höchstens 4097 Bytes der Antwort. Akzeptiert wird ausschließlich die exakte, einzelne und korrekt abgeschlossene Sauber-Antwort. Leere, abgeschnittene, übergroße, mehrfache oder unbekannte Antworten führen zu keiner Freigabe. Eine Infektion oder das Erreichen eines Engine-Grenzwerts gilt als nicht sauber. Eine Frist von zehn Sekunden umfasst das Lesen der Datei und die Kommunikation mit clamd.
Alles zusammenführen: src/main.rs
Speichern Sie dieses vollständige Programm als src/main.rs:
use clap::Parser;
use notify::{Event, EventKind, RecursiveMode, Watcher};
use std::{error::Error, path::{Path, PathBuf}, time::Duration};
use tokio::{
fs::{self, File},
io::{AsyncReadExt, AsyncWriteExt},
net::UnixStream,
sync::mpsc,
time::{interval, timeout},
};
type AppResult<T> = Result<T, Box<dyn Error + Send + Sync>>;
const MAX_BYTES: u64 = 10 * 1024 * 1024;
#[derive(Parser)]
struct Args {
#[arg(long)]
directory: PathBuf,
#[arg(long, default_value = "/var/run/clamav/clamd.ctl")]
socket: PathBuf,
#[arg(long)]
once: bool,
}
#[derive(Debug, PartialEq)]
enum Verdict {
Clean,
Infected,
ScannerError,
}
fn parse_reply(reply: &[u8]) -> Verdict {
if reply == b"stream: OK\0" {
return Verdict::Clean;
}
if let Some(name) = reply.strip_prefix(b"stream: ")
.and_then(|value| value.strip_suffix(b" FOUND\0"))
{
if !name.is_empty() && !name.iter().any(|byte| byte.is_ascii_control()) {
return Verdict::Infected;
}
}
Verdict::ScannerError
}
async fn scan(path: &Path, socket: &Path) -> AppResult<Verdict> {
// The private-directory producer contract forbids modifying published .ready files.
if !fs::symlink_metadata(path).await?.file_type().is_file() {
return Err("Input must be a regular, non-symlink file".into());
}
let file = File::open(path).await?;
if !file.metadata().await?.is_file() {
return Err("Input must be a regular file".into());
}
let mut bytes = Vec::new();
file.take(MAX_BYTES + 1).read_to_end(&mut bytes).await?;
if bytes.len() as u64 > MAX_BYTES {
return Err("Input exceeds 10 MiB".into());
}
let mut stream = UnixStream::connect(socket).await?;
stream.write_all(b"zINSTREAM\0").await?;
for chunk in bytes.chunks(64 * 1024) {
stream.write_all(&(chunk.len() as u32).to_be_bytes()).await?;
stream.write_all(chunk).await?;
}
stream.write_all(&0u32.to_be_bytes()).await?;
let mut reply = Vec::new();
stream.take(4097).read_to_end(&mut reply).await?;
Ok(if reply.len() > 4096 {
Verdict::ScannerError
} else {
parse_reply(&reply)
})
}
async fn scan_directory(args: &Args) -> AppResult<bool> {
let mut entries = fs::read_dir(&args.directory).await?;
let mut all_clean = true;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if path.extension().and_then(|value| value.to_str()) != Some("ready") {
continue;
}
let verdict = match timeout(Duration::from_secs(10), scan(&path, &args.socket)).await {
Ok(Ok(verdict)) => verdict,
_ => Verdict::ScannerError,
};
all_clean &= verdict == Verdict::Clean;
// Debug path formatting escapes newlines and control characters in filenames.
println!("{verdict:?} {:?}", path.file_name());
}
Ok(all_clean)
}
async fn monitor(args: &Args) -> AppResult<bool> {
if args.once {
return scan_directory(args).await;
}
let (tx, mut rx) = mpsc::channel(1);
let mut watcher = notify::recommended_watcher(move |event: notify::Result<Event>| {
let changed = match event {
Ok(event) => matches!(
event.kind,
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
),
Err(_) => {
eprintln!("Watcher error; periodic reconciliation remains active.");
true
}
};
if changed {
// A full queue already guarantees a complete directory reconciliation.
let _ = tx.try_send(());
}
})?;
watcher.watch(&args.directory, RecursiveMode::NonRecursive)?;
let mut ticks = interval(Duration::from_secs(5));
loop {
tokio::select! {
_ = ticks.tick() => {}
event = rx.recv() => {
if event.is_none() {
return Err("Watcher channel closed".into());
}
}
}
scan_directory(args).await?;
}
}
async fn run() -> AppResult<bool> {
let args = Args::parse();
tokio::select! {
result = monitor(&args) => result,
result = tokio::signal::ctrl_c() => {
result?;
Err("Scanning interrupted".into())
}
}
}
#[tokio::main]
async fn main() {
match run().await {
Ok(true) => {}
Ok(false) => std::process::exit(1),
Err(_) => {
eprintln!("Scanner stopped; check the directory, socket, and daemon configuration.");
std::process::exit(1);
}
}
}
Kompilieren und starten Sie das Überwachungsprogramm mit einem leeren, privaten Eingangsverzeichnis:
mkdir -m 700 inbox
cargo build
cargo run -- --directory inbox --socket /var/run/clamav/clamd.ctl
Stellen Sie in einem weiteren Terminal vom Projektverzeichnis aus eine fertig geschriebene Datei unter einem neuen Namen bereit:
printf 'A completed example file.\n' > inbox/example.part
mv inbox/example.part inbox/example.ready
Ein sauberes Scan-Ergebnis gibt Clean aus. Halten Sie Dateien mit der Endung .part aus der Eingabemenge des Scanners heraus. Benennen Sie eine Datei erst um, nachdem der schreibende Prozess sie geschlossen hat, und verwenden oder ändern Sie einen bereitgestellten Pfad mit der Endung .ready nicht erneut.
Fehlerbehandlung und Benachrichtigungen
ScannerError bedeutet, dass die Datei nicht freigegeben wurde. Infected umfasst auch die in ClamAV konfigurierten Warnungen bei Grenzwertüberschreitungen. Keines dieser Ergebnisse sollte die Bereitstellung einer Datei auslösen. Das Beispiel belässt bewusst jede Datei an ihrem Speicherort, damit ein fehlgeschlagener Scan keinen Upload löschen kann.
Ctrl-C verwirft das aktive Scan-Future, schließt dessen Socket, gibt den Watcher frei und beendet das Programm mit einem Exit-Code ungleich null. Im Überwachungsmodus wird ein fehlgeschlagener Scan bei einem späteren Abgleich wiederholt. Verwenden Sie --once, wenn ein aufrufendes Programm für einen Stapel mit nicht sauberen Ergebnissen einen Fehler-Exit-Code benötigt.
Überlegungen zur Leistung
Es gibt jeweils einen aktiven Scan, ein ausstehendes Aufwecksignal, eine größenbegrenzte Momentaufnahme pro Datei und eine größenbegrenzte Antwort. Beim Auflisten des Verzeichnisses entsteht keine Warteschlange von Pfaden. Alle fertig geschriebenen Dateien erneut zu scannen, kostet Durchsatz, hält das Beispiel aber klein und ermöglicht die Wiederaufnahme nach Fehlern. Ein größerer Dienst sollte ausstehende Arbeit dauerhaft erfassen und jedes Ergebnis dem exakten unveränderlichen Objekt zuordnen, das gescannt wurde.
Dieses Überwachungsprogramm arbeitet nicht rekursiv. Das Intervall von fünf Sekunden legt den Abgleichrhythmus fest und garantiert keine Erkennung innerhalb dieser Frist. Große Stapel und langsame Scans benötigen mehr Zeit. Halten Sie die Signaturen aktuell und testen Sie die Archivgrenzwerte Ihres Daemons im Rahmen des laufenden Betriebs.
Fazit
Die Übergabe durch den Erzeuger verhindert, dass teilweise geschriebene Dateien für vollständig gehalten werden. Notify sorgt für zeitnahes Aufwecken, regelmäßige erneute Scans gleichen verpasste Ereignisse aus, und die explizite Protokollvalidierung verhindert, dass mehrdeutige Scannerergebnisse als sauber gelten.
Transloadit nutzt ClamAV auch zur Dateifilterung mit dem Robot 🤖 /file/virusscan.
