044 —Workflow
Twee SFTP-trees diffen: vind het bestand dat veranderde
Zaterdagochtend, 09:14. De klant zweert dat er niets veranderd is sinds vrijdag. Drie shell-commands die het bestand vinden dat het wél deed.
Een Nederlands bureau waarmee we samenwerken kreeg het bericht zaterdag om 09:14 binnen: het contactformulier op een negen jaar oude WordPress-site gaf bij verzenden plotseling een 500 terug. De klant, een regionaal logistiek bedrijf, was stellig over één ding. Niemand had iets aangeraakt sinds vrijdagmiddag. 'We zijn letterlijk om 17:00 naar huis gegaan en hebben de laptop niet meer opengeklapt.' De dienstdoende dev moest dat geloven of het tegendeel bewijzen vóór de lunch.
Dit is de standaardvorm van een SFTP-supportcall. Iets werkt op vrijdag. Iets is stuk op maandag. De klant zweert dat er niets is veranderd. De waarheid is bijna altijd een klein bestand dat niemand zich herinnert geschreven te hebben. Het werk bestaat eruit dat bestand te vinden zonder Git-historie, zonder deploy-log, en zonder de ochtend van de klant op te branden.
De mtime-leugen
De eerste reflex in een unix-shell is find . -mtime -3. Op een via FTP beheerde site liegt dat commando ongeveer in de helft van de gevallen. De meeste FTP- en SFTP-clients (FileZilla, Cyberduck, Transmit) bewaren de originele modificatietijd bij het uploaden, dus een bestand dat gisteren is geüpload kan een mtime uit 2019 dragen. Erger nog: sommige plugin-updaters resetten de mtime zodat die overeenkomt met de upstream-zip.
Het veld dat niet liegt is ctime (inode change time op linux), dat opnieuw wordt gezet elke keer dat de inode zelf wordt herschreven, inclusief wanneer het bestand wordt vervangen. De meeste FTP-servers kunnen ctime niet vanaf de clientkant zetten, omdat het geen exposed metadata-veld is.
find . -type f -ctime -3 -printf '%T@ %p\n' | sort -n
Dit haalt alles boven water waarvan de inode in de laatste 72 uur is veranderd, gesorteerd op mtime. Op een normale WordPress-installatie hoort dat een korte lijst te zijn: misschien een cache-directory, misschien een session-bestand, misschien helemaal niets. Eén onverwachte hit in wp-content/plugins/ of in de docroot is meestal het antwoord.
De catch: ctime wordt gereset als iemand chmod of chown heeft losgelaten op een tree, wat managed hosts soms doen tijdens een nachtelijke sweep. Kijk naar de spreiding van de timestamps. Als de hele wp-content/uploads-tree dezelfde ctime deelt tot op de seconde, was dat een bulk-operatie, geen file-edit.
Een baseline opbouwen
Als je langere tijd met een site werkt, is de goedkoopste verzekering die je voor jezelf kunt afsluiten een checksum-manifest. Eén keer per week, via cron of met de hand:
cd /var/www/clientsite
find . -type f \
-not -path './wp-content/cache/*' \
-not -path './wp-content/uploads/*' \
-exec md5sum {} \; \
| sort -k 2 > ~/baselines/clientsite-2026-05-23.md5
Het bestand is 2 tot 3 MB voor een typische WordPress-installatie. Komt het zaterdagticket binnen, dan mirror je de huidige site naar /tmp/audit, draai je hetzelfde commando ertegen, en dan:
diff ~/baselines/clientsite-2026-05-23.md5 ~/audit/clientsite-now.md5
Elke regel links zonder exacte match rechts is een bestand dat is gewijzigd, verwijderd of toegevoegd. De diff voor een echt 'er is niets gebeurd'-incident is meestal één tot zes regels lang, precies de hoeveelheid bewijs die je in een Basecamp-reply kunt plakken.
Twee trees, één rsync dry-run
Als je geen baseline hebt, vergelijk je tegen het dichtstbijzijnde wat je wél hebt: je laatste lokale pull, de staging-kopie, of een backup-tarball van je host. rsync in dry-run-modus met itemized changes is de snelste manier om de delta te zien.
rsync -avn --itemize-changes --delete \
--exclude='wp-content/cache/' \
--exclude='wp-content/uploads/' \
./local-friday-pull/ \
sftp-user@host:/var/www/clientsite/
De interessante kolom is het meest linkse flag-blok. >f.st.... betekent dat een bestand verzonden zou worden omdat het op de source nieuwer is. *deleting betekent dat een lokaal aanwezig bestand op de remote ontbreekt. Het patroon dat je wilt zien is kort. Als rsync honderden bestanden terug wil pushen, is dat permission- of umask-drift; wil het er twee terug pushen, dan heb je je antwoord.
Let op de n in -avn. Dat is dry-run. Zonder die n overschrijft rsync vrolijk de live site met je verouderde kopie van vrijdag, en dat is de op één na slechtste zaterdaguitkomst.
Waar de wijziging echt zit
Zodra de diff voor je staat, zit het in tachtig procent van de gevallen op één van vijf plekken.
.htaccessin de docroot, vooral nadat een security-plugin zijn eigen block opnieuw heeft geschreven.wp-config.php, waar iemandWP_DEBUGheeft omgezet of een constante heeft toegevoegd.wp-content/plugins/<naam>/voor een auto-geüpdatete plugin (auto-updates staan sinds WP 5.5 standaard aan).wp-content/mu-plugins/, een map waarvan de meeste klanten niet weten dat hij bestaat en waar veel hosts iets in injecteren.- De
functions.phpvan een theme die een 'marketingmedewerker met FTP-toegang' om 16:58 op vrijdag heeft aangepast.
Niets hiervan vereist Git of een deploy-pipeline. Het vereist weten waar je moet kijken, een baseline die je vertrouwt, en een diff die je aan de klant kunt overhandigen.
De MySQL-kant van dezelfde vraag
Filesystem-diffs vertellen maar de helft van het verhaal. De andere helft zit in wp_options, wp_postmeta en de cron-tabel. Een kapot contactformulier is net zo vaak een plugin die automatisch is gedeactiveerd als een file-wijziging. Een controle waard:
SELECT option_name, option_value
FROM wp_options
WHERE option_name IN ('active_plugins', 'cron', 'siteurl', 'home');
Vergelijk dit met je laatste known-good export. Als active_plugins tussen vrijdag en zaterdag een entry kwijt is, heeft iemand (of iets) hem gedeactiveerd. WP-CLI heeft de schoonste read: wp option get active_plugins --format=json.
Wat we uiteindelijk bouwden
Toen we Pier bouwden voor ons eigen werk aan verouderde sites, was dit zaterdagochtend-scenario het ding waar we keer op keer tegenaan liepen. We hebben het uiteindelijk zo opgelost dat we bij het verbinden een snapshot maken van de SFTP-tree en de database, en daarna elke wijziging tonen in een version history naast de vorige inhoud van het bestand. Dezelfde view bestaat voor de MySQL editor, dus een gedeactiveerde plugin of een omgezette siteurl komt op dezelfde manier boven water als een aangeraakte .htaccess. De diff staat naast de undo-knop, en dat is de volgorde waarin je ze om 09:14 op een zaterdag wilt hebben.
Het kleinste wat je vandaag kunt doen
Open de SFTP-root van één site die je onderhoudt. Draai het md5-manifest-commando hierboven, redirect de output naar een baseline-bestand, zet dat bestand in een map die baselines/ heet. Kosten: zo'n negentig seconden en 3 MB schijfruimte. De volgende keer dat een klant zegt dat er niets veranderd is sinds vrijdag, heb je iets om tegen te diffen, en duurt het gesprek twaalf minuten in plaats van twee uur.
— Vragen —
Waarom laat mtime niet betrouwbaar zien welke bestanden recent zijn gewijzigd via FTP?
De meeste FTP- en SFTP-clients behouden bij het uploaden de originele mtime van het bron-bestand, dus een bestand dat gisteren is geüpload kan een timestamp van jaren geleden dragen. Gebruik op linux ctime voor de waarheid.
Hoe groot is een typisch md5-baseline-bestand voor WordPress?
Met cache en uploads uitgesloten kun je 2 tot 3 MB op schijf verwachten, en grofweg 1.200 tot 4.000 regels, afhankelijk van het aantal geïnstalleerde plugins en taalbestanden.
Kan rsync twee remote SFTP-servers direct met elkaar vergelijken?
Niet uit zichzelf. Mirror beide eerst naar lokale kopieën (lftp mirror is het snelst) en draai dan rsync --dry-run tussen de twee lokale trees.