Echtzeit-Verzeichnisüberwachung und Virenscans mit Rust und ClamAV
Erstellen Sie einen Rust-Monitor, der fertig geschriebene Dateien mit einem lokalen ClamAV-Daemon scannt. Er meldet unauffällige, infizierte und durch Scannerfehler fehlgeschlagene Ergebnisse, ohne Dateien zu verschieben oder zu löschen. Nur eine explizit unauffällige Antwort gilt als unauffällig.
Warum Echtzeit-Überwachung und Rust?
Dateisystemereignisse können eintreffen, bevor ein Schreibvorgang abgeschlossen ist. Eine Wartezeit
oder eine unveränderte Dateigröße belegt keinen Abschluss. Dieses Beispiel verwendet daher eine
Vereinbarung mit dem erzeugenden Prozess: Schreiben Sie eine eindeutig benannte Datei mit der Endung
.part, schließen Sie sie und benennen Sie sie dann im selben Verzeichnis atomar in
.ready um. Ändern Sie niemals eine Datei mit der Endung
.ready. Halten Sie das Verzeichnis ausschließlich für die Anwendung und ihren
vertrauenswürdigen erzeugenden Prozess zugänglich.
Der Monitor scannt nur Dateien mit der Endung .ready auf der obersten Ebene.
Er dient der Benachrichtigung, nicht der Zugriffskontrolle: Ein Ergebnis beschreibt die gescannten
Bytes und autorisiert keinen späteren Lesezugriff auf einen veränderbaren Pfad.
Voraussetzungen
Diese Anleitung verwendet Linux und Bash sowie Rust und Cargo 1.98.1 und ClamAV 1.5.4. Stellen Sie
einen lokalen Daemon clamd mit aktueller Signaturdatenbank und einem Unix-Socket
bereit, auf den der Rust-Prozess zugreifen kann. Die folgenden Befehle sind für Linux vorgesehen;
der Client verwendet Unix-Sockets.
ClamAV-Daemon (clamd) einrichten
Unter Debian oder Ubuntu führt die ClamAV-Paketanleitung die Pakete für den Daemon und die Aktualisierung der Signaturdatenbank auf:
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 auf 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 manuelles Aktualisierungsprogramm für dessen gesperrte Datenbank aus.
sudo systemctl enable --now clamav-freshclam &&
sudo systemctl restart clamav-daemon
Die ClamAV-Protokolldokumentation
beschreibt den Socket und das INSTREAM-Framing. Clamd bietet keine TCP-Authentifizierung;
dieses Beispiel verwendet ausschließlich einen lokalen Socket mit Zugriffskontrolle über
Berechtigungen. Konfigurieren Sie Archiv- und Scanlimits passend zu Ihrer Arbeitslast. Wenn
AlertExceedsMax aktiviert ist, dürfen die
Warnungen des Daemons bei Limitüberschreitung
nicht als unauffälliges Ergebnis gewertet werden.
Projekt einrichten
Beginnen Sie in einem beschreibbaren Verzeichnis außerhalb eines bestehenden Cargo-Projekts.
Cargo kann ein neues Paket zu einem übergeordneten Workspace hinzufügen,
und die Konfiguration wird aus übergeordneten Verzeichnissen geerbt.
Dieser Bash-Block lehnt solche Speicherorte ab, bevor er etwas erstellt. Er wechselt erst in das neue
Projekt, nachdem cargo new erfolgreich war; ein vorhandenes Verzeichnis
realtime_virus_scanner bleibt unangetastet.
(
cd -P . || exit 1
scanner_parent=$PWD
while :; do
if [ -e "$scanner_parent/Cargo.toml" ] ||
[ -e "$scanner_parent/.cargo/config.toml" ] ||
[ -e "$scanner_parent/.cargo/config" ]; then
printf 'Choose a directory outside existing Cargo projects/configuration.\n' >&2
exit 1
fi
[ "$scanner_parent" = / ] && break
scanner_parent=${scanner_parent%/*}
[ -n "$scanner_parent" ] || scanner_parent=/
done
cargo new realtime_virus_scanner --edition 2021 --vcs none
) && cd realtime_virus_scanner
Ersetzen Sie nach erfolgreicher Einrichtung die Datei Cargo.toml im neuen Projekt
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-Austausch direkt mit Tokio, statt von einem separaten ClamAV-Client-Wrapper abhängig zu sein.
Befehlszeilenschnittstelle mit clap
Die CLI akzeptiert --directory und --socket. Ergänzen Sie
--once, um die vorhandenen fertig geschriebenen Dateien zu scannen und das
Programm zu beenden: Null bedeutet, dass jede ausgewählte Datei unauffällig war; eins bedeutet, dass
eine Infektion oder ein Scannerfehler aufgetreten ist. Auch eine leere Auswahl führt zum Exit-Code
null; dies sagt nichts über unfertige Dateien mit der Endung .part aus.
Der Überwachungsmodus wiederholt Scans bis Strg+C und meldet jedes Ergebnis.
Echtzeit-Verzeichnisüberwachung mit notify
Ein empfohlener Watcher aktiviert den Scanner nach einem Erstellungs-, Änderungs- oder Löschereignis. Ein Kanal mit einer Speicherposition fasst Ereignisse zusammen, da jede Aktivierung das Verzeichnis erneut durchsucht. Ein Abgleichintervall von fünf Sekunden findet Dateien auch dann, wenn Ereignisse verpasst werden. Lese- und Zugriffsereignisse werden ignoriert, damit Scans sich nicht selbst auslösen.
Asynchrone Virenscans mit Tokio
Jeder Scan erstellt einen Snapshot mit begrenzter Größe, überträgt ihn in Blöcken von 64 KiB und liest höchstens 4097 Bytes der Antwort. Nur die exakte, einzelne und korrekt abgeschlossene Antwort für einen unauffälligen Scan wird akzeptiert. Leere, abgeschnittene, übergroße, mehrfache oder unbekannte Antworten führen zur Ablehnung. Eine Infektion oder eine erkannte Überschreitung der Engine-Limits gilt nicht als unauffällig. 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 den Monitor aus dem Projektverzeichnis mit einem neuen privaten Eingangsverzeichnis. Jeder Schritt muss erfolgreich sein, bevor der nächste ausgeführt wird, damit ein fehlgeschlagener Build keine alte ausführbare Datei starten kann:
mkdir -m 700 inbox &&
cargo build --jobs 2 --target-dir target &&
./target/debug/realtime_virus_scanner --directory inbox --socket /var/run/clamav/clamd.ctl
Ändern Sie das Socket-Argument, falls Ihr Daemon einen anderen Pfad verwendet. Lassen Sie dieses Terminal laufen. Stellen Sie in einem anderen Terminal aus dem Projektverzeichnis eine fertig geschriebene Datei mit einem unbenutzten Namenspaar bereit:
(
set -o noclobber
test ! -e inbox/example.ready &&
test ! -L inbox/example.ready &&
test ! -e inbox/example.part &&
test ! -L inbox/example.part &&
printf 'A completed example file.\n' > inbox/example.part &&
mv -n -- inbox/example.part inbox/example.ready &&
test ! -e inbox/example.part
)
Ein unauffälliger Scan gibt Clean Some("example.ready") aus, möglicherweise mehrfach, da die
Abgleiche fertig geschriebene Dateien erneut scannen. Der Block für den erzeugenden Prozess liefert
einen Exit-Code ungleich null, falls einer der Namen bereits existiert, ohne dessen Inhalt zu
ersetzen. Verwenden Sie für spätere Dateien neue Namen und führen Sie erzeugende Prozesse nicht
gleichzeitig mit denselben Namen aus. Der Befehl printf schließt die Datei,
bevor mv sie bereitstellt.
Drücken Sie im Monitor-Terminal Strg+C, um ihn zu beenden. Um dasselbe Eingangsverzeichnis einmal zu scannen, führen Sie Folgendes aus:
./target/debug/realtime_virus_scanner --directory inbox --socket /var/run/clamav/clamd.ctl --once
Fehlerbehandlung und Benachrichtigungen
ScannerError bedeutet, dass der Scan fehlgeschlagen ist, etwa wegen eines nicht
verfügbaren Sockets, einer Eingabe über 10 MiB, eines symbolischen Links oder einer Fristüberschreitung.
Infected umfasst auch die konfigurierten Warnungen von ClamAV bei
Limitüberschreitung. Keines dieser Ergebnisse sollte eine Datei für nachgelagerte Verbraucher
freigeben. Das Beispiel belässt bewusst jede Datei an ihrem Speicherort, damit ein fehlgeschlagener
Scan keinen Upload löschen kann.
Strg+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 erneut versucht. Verwenden Sie --once, wenn ein
aufrufender Prozess für einen nicht unauffälligen Stapel einen Fehler-Exit-Code benötigt.
Überlegungen zur Leistung
Es gibt jeweils einen aktiven Scan, eine ausstehende Aktivierung, einen Snapshot mit begrenzter Größe pro Datei und eine begrenzte Antwort. Beim Auflisten des Verzeichnisses wird keine Warteschlange von Pfaden aufgebaut. Das erneute Scannen aller fertig geschriebenen Dateien tauscht Durchsatz gegen ein kleines Beispiel, das sich nach Fehlern wieder erholen kann; ein größerer Dienst sollte Aufgaben dauerhaft nachverfolgen und jedes Scanurteil dem exakten unveränderlichen Objekt zuordnen, das geprüft wurde.
Dieser Monitor arbeitet nicht rekursiv, und ein Intervall von fünf Sekunden ist ein Zeitplan für den Abgleich, keine garantierte Erkennungsfrist. Große Stapel und langsame Scans dauern länger. Halten Sie Signaturen aktuell und testen Sie die Archivlimits Ihres Daemons als Teil des Dienstbetriebs.
Fazit
Nur vertrauenswürdige erzeugende Prozesse sollten Dateien mit der Endung
.ready bereitstellen, und Verbraucher dürfen diesen Monitor nicht als
Berechtigung zu deren Auslieferung betrachten. Clean bedeutet, dass die
konfigurierte ClamAV-Engine in den gescannten Bytes keinen Fund gemeldet hat, nicht, dass die Datei
harmlos ist. Halten Sie Signaturen aktuell und wählen Sie Daemon-Limits passend zu den Formaten, die
Sie akzeptieren.
Transloadit verwendet ClamAV auch zur Dateifilterung mit seinem Robot 🤖 /file/virusscan.
