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 events steht, 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.