iwatch ablösen: 20 000 Mails, eine XML-Datei und ein Wochenende Rust
Worum es geht Link zu Überschrift
Auf meinem Server lief seit Jahren iwatch. Es überwacht Verzeichnisse mit inotify und meldet Änderungen per Mail. Das Werkzeug tut, was es soll — es tut es nur auf eine Art, die mit der Zeit unbrauchbar wurde.
Zwei Dinge haben mich dazu gebracht, es zu ersetzen.
Eine Mail pro Ereignis. Ein Kernel-Update fasst Tausende Dateien an. Das Postfach bekommt Tausende Mails, alle innerhalb weniger Sekunden. Was dazwischen tatsächlich verdächtig war, findet man nicht mehr.
Die Konfiguration. Ein Auszug aus meiner alten iwatch.xml:
<path type="recursive">/dev</path>
<path type="exception">/dev/shm/libpod_rootless_lock_5001</path>
<path type="exception">/dev/shm/libpod_rootless_lock_1002</path>
<path type="regexception">.*PostgreSQL.*</path>
Das sieht harmloser aus, als es ist. exception ist ein exakter Pfad,
regexception ein regulärer Ausdruck ohne Anker. Letzterer steht optisch unter
/dev, wirkt aber auf dem ganzen System. Und weil es Platzhalter nur in der
Regex-Variante gibt, habe ich die Podman-Locks für jede UID einzeln
hingeschrieben — statt einmal libpod_rootless_lock_*.
Irgendwann habe ich aufgehört, die Datei zu pflegen. Genau das ist das Problem: Ein Überwachungswerkzeug, dessen Konfiguration man nicht mehr anfasst, überwacht irgendwann das Falsche.
Die zwei Entscheidungen Link zu Überschrift
Bündeln statt einzeln. Ereignisse werden in einem Zeitfenster gesammelt — bei mir 10 Sekunden — und als eine Mail verschickt. Aus 20 000 Mails werden eine Handvoll.
Zwei Listen statt einer. include und exclude, beide ohne
Reihenfolge-Bedeutung, exclude gewinnt immer:
include = ["/var/lib/roundcube/**"]
exclude = ["/var/lib/roundcube/logs/**"]
Ich war lange überzeugt, ich bräuchte drei Ebenen — einschliessen, davon ausschliessen, davon wieder einschliessen. Als ich meine alte XML-Datei Regel für Regel durchgegangen bin, war keine einzige davon dreistufig. Jede war: Baum X überwachen, Ding Y darin nicht. Das Gefühl von Verschachtelung kam nicht vom Bedarf, sondern von den zwei unterschiedlichen Ausnahme-Arten.
Nebeneffekt: Was nicht in include steht, wird gar nicht erst beim Kernel
angemeldet. In der alten Datei stand /proc als recursive drin und wurde
anschliessend per regexception komplett weggeworfen — zigtausend Watches für
nichts.
Was modify mit dem Rauschen zu tun hat
Link zu Überschrift
Der grösste einzelne Rauschverursacher heisst IN_MODIFY. Es feuert bei jedem
Schreibvorgang. Eine 200-MB-Datei zu kopieren erzeugt Tausende Ereignisse für
eine Datei.
IN_CLOSE_WRITE feuert genau einmal: wenn die Datei fertig geschrieben und
geschlossen ist. Das ist fast immer das, was man eigentlich wissen will.
Der Standardsatz ist deshalb:
events = ["create", "close_write", "attrib", "delete", "moved"]
modify lässt sich einschalten, wer es braucht — etwa für Logdateien, die ein
Prozess dauerhaft offen hält und die deshalb nie ein close_write auslösen.
Merksatz: Was nicht in
eventssteht, filtert der Kernel selbst. Es erreicht den Daemon gar nicht und kostet auch nichts.
Lesen ist übrigens unkritisch: Ein cat erzeugt IN_ACCESS und
IN_CLOSE_NOWRITE, nicht IN_CLOSE_WRITE.
Rust, ohne Rust zu können Link zu Überschrift
Ich habe vorher kein Rust geschrieben. Java, C und dreissig Jahre Linux, aber kein Rust. Der Neubau war eine gute Gelegenheit.
Zwei Dinge haben dabei geholfen, mehr als die Sprache selbst.
Testgetrieben, und die Zeit kommt von aussen. Der Kern — filtern, bündeln, Mailtext bauen — kennt weder Kernel noch Uhr noch Mailsystem. Statt die Uhrzeit im Code abzufragen, wird sie als Parameter übergeben:
pub fn is_due(&self, now: Instant, window: Duration) -> bool {
!self.is_empty() && now.duration_since(self.window_start) >= window
}
Damit lässt sich „was passiert nach 10 Sekunden" prüfen, ohne 10 Sekunden zu warten. Die gesamte Testsuite läuft in Millisekunden — keine echten Dateien, keine Mails, kein Warten.
enum plus match. Als ich später zwei Ereignisarten ergänzt habe, hat der
Compiler mir genau die Stelle gezeigt, an der die Behandlung fehlte. Kein
vergessener Fall, kein stiller Durchrutscher.
Die Ownership-Regeln waren erwartungsgemäss der Punkt, an dem ich am häufigsten gegen den Compiler gelaufen bin. Die Fehlermeldungen sind allerdings so gut, dass es beim Lernen mehr hilft als bremst.
Vier Dinge, die erst beim Durchsehen auffielen Link zu Überschrift
Nach dem ersten lauffähigen Stand bin ich das Projekt noch einmal unter Sicherheitsgesichtspunkten durchgegangen. Das hat sich gelohnt.
Symlinks. Ich hatte entry.metadata() benutzt, um Verzeichnisse zu erkennen.
Das folgt dem Symlink. Ein Link auf / hätte das ganze Dateisystem in die
Überwachung gezogen. Richtig ist entry.file_type() — das folgt nicht.
Stille Fehlschläge. Wenn ein Watch nicht angemeldet werden kann — etwa weil
max_user_watches erreicht ist — wurde das Verzeichnis kommentarlos
übersprungen. Der Daemon hätte dann mit halber Abdeckung gelaufen und dabei
gesund ausgesehen. Das ist bei einem Sicherheitswerkzeug die schlimmste Variante:
Es meldet nichts, und man hält das für ein gutes Zeichen.
Unbegrenzter Speicher. Ist das Mailsystem weg, sammelt der Daemon weiter. Es
gibt jetzt ein max_events; bei Erreichen geht die Mail sofort raus.
Header Injection. Der Hostname landet im Subject. Dateinamen dürfen unter
Linux fast alles enthalten, auch Zeilenumbrüche — und /tmp steht bei mir in
include. Ohne Bereinigung könnte jemand mit Schreibrecht dort eigene
Kopfzeilen einschmuggeln, etwa ein Bcc:. Der Test dazu war übrigens zuerst
falsch: Ich habe geprüft, ob "Bcc:" im Text vorkommt, statt ob eine zusätzliche
Kopfzeile entstanden ist.
Was der Empfänger nicht sieht, kann er nicht einschätzen. Deshalb tragen
unvollständige Berichte — Kernel-Überlauf oder max_events — jetzt ein
INCOMPLETE im Betreff.
Wie es aussieht Link zu Überschrift
[fwatchd] server1: 3 changes
3 changes on server1
2026-09-12 14:02:11 modified /etc/passwd
2026-09-12 14:02:14 created /etc/hosts
2026-09-12 14:02:19 deleted /etc/fstab
Vor dem Scharfschalten lässt sich die Konfiguration prüfen, ohne dass etwas läuft:
fwatchd check /etc/fwatchd/fwatchd.toml /etc/passwd /proc/1/status
WATCHED /etc/passwd
ignored /proc/1/status
Das ist der Teil, den ich bei iwatch am meisten vermisst habe. Meine alte XML enthielt eine Ausnahme mit einem verirrten Anführungszeichen am Zeilenende. Sie hat vermutlich nie funktioniert, und gemerkt habe ich es erst beim Übersetzen. Unbekannte Schlüssel führen jetzt zum Abbruch beim Start statt zu stillem Ignorieren.
Was offen ist Link zu Überschrift
Es hängt weiterhin an inotify, also an einem Watch pro Verzeichnis — rund ein
Kilobyte Kernelspeicher, der nicht ausgelagert werden kann. fanotify würde sich
stattdessen an ein ganzes Dateisystem hängen, braucht aber CAP_SYS_ADMIN und
hat in Rust kaum brauchbare Bibliotheken. Die Ereignisquelle ist im Code deshalb
austauschbar gebaut; falls es nötig wird, kommt sie als zweiter Adapter dazu.
Der Überlauf bleibt in beiden Fällen ein Thema. Er lässt sich nicht wegdefinieren — nur ehrlich melden.
Quellcode und Pakete: codeberg.org/randomDuderus/fwatchd, Lizenz MIT. Fragen, Korrekturen und eigene Erfahrungen gerne in die Kommentare.