— Artikel — № 024

024 —Workflow

Cron jobs via FTP: een overgenomen server doorlichten

Je erft een site met een FTP-login en een database-dump. Ergens draaien er taken op schema. Vind ze, voor er één om 03:00 op zondag stuk gaat.

Messing scheepsbel op donkere eikenhouten beugel tegen botkleurige muur, hennep koord met roodbruin wassegel-label.
Hero · gestileerd stilleven№ 024

De overdrachtsmail beslaat twee alinea's. "Login staat in de password manager. Site draait al jaren stabiel. Vorige dev is in 2023 vertrokken." Bijgevoegd zitten een SFTP-account en een MySQL-gebruiker. Geen shell, geen controlepaneel, geen documentatie over wat er op een schema draait. Ergens op die machine vuurt iets elke vijf minuten, elke nacht om 02:00, of één keer per maand op de eerste. Je moet ze allemaal vinden voor er één onder jouw beheer stukgaat.

Cron jobs via FTP zijn een eigen vorm van forensisch werk. Je kunt geen crontab -l draaien. Je kunt geen ls /etc/cron.d/ doen. Wat je wél kunt: de sporen lezen die geplande taken achterlaten, die kruisen met de staat van de database, en het schema van buitenaf reconstrueren. Dit is de volgorde die ik aanhoud bij elke overname van een legacy site.

Sporen in het bestandssysteem

Shared hosts sluiten de FTP-gebruiker bijna altijd op in één home directory. /etc is onbereikbaar. Maar cron jobs laten vingerafdrukken achter binnen de docroot, en daar begin je.

Zoek eerst naar logbestanden die de site zelf wegschrijft. De meeste CMS'en droppen ergens een log op een voorspelbare plek:

/wp-content/debug.log
/wp-content/uploads/wc-logs/*.log
/sites/default/files/php_errors.log
/var/log/magento.log
/var/reports/
/storage/logs/laravel.log

Open ze en grep op "cron". WordPress schrijft bij elke run een regel zoals WP-Cron: doing event 'woocommerce_cleanup_sessions'. Magento schrijft cron_schedule-regels naar var/log/system.log. Drupal logt elke cron-run met username "Anonymous" en path "/cron". Zelfs als de cron extern wordt getriggerd door een curl uit /etc/crontab, logt de applicatie zelf het event, en dat log staat binnen de FTP-root.

Doe daarna een recursieve mtime-sweep. Cron-gedreven scripts schrijven naar disk. Back-ups belanden ergens. Caches worden geroteerd. Exportfeeds worden opnieuw gegenereerd. Als je een directory met mtimes kunt opvragen, zoek dan naar bestanden waarvan de timestamps in een regelmatig ritme vallen:

backup-2026-04-14-0300.sql.gz
backup-2026-04-15-0300.sql.gz
backup-2026-04-16-0300.sql.gz
feed-google-shopping-20260514.xml
feed-google-shopping-20260515.xml

Dat is een dagelijkse back-up om 03:00 en een dagelijkse Google Shopping-export. Je hebt de cron-regel zelf niet gevonden. Je hebt het bewijs gevonden dat die bestaat, en nu weet je welk script in de docroot die bestanden heeft weggeschreven.

De database onthoudt alles

Dan het tweede deel. Schedulers op applicatieniveau bewaren hun queue in MySQL, en MySQL heb je. Drie tabellen tellen, afhankelijk van de stack.

WordPress

WordPress bewaart zijn volledige cron-schema geserialiseerd in één rij:

SELECT option_value
FROM wp_options
WHERE option_name = 'cron';

De waarde is een PHP-geserialiseerde array met unix-timestamps als sleutel. Unserialise hem (een PHP-oneliner volstaat) en je krijgt de complete lijst met aankomende events, de hooknamen, het herhalingsinterval en de meegegeven argumenten. De officiële WP-Cron-documentatie is hier het herlezen waard. De valkuil: wp-cron vuurt alleen als iemand de site bezoekt, tenzij een echte system cron wp-cron.php periodiek pingt.

Magento

Magento bewaart zijn queue in cron_schedule. Eén rij per geplande run:

SELECT job_code, status, scheduled_at, executed_at, finished_at
FROM cron_schedule
ORDER BY scheduled_at DESC
LIMIT 50;

Groepeer op job_code en je hebt elke job die ooit heeft gedraaid, plus zijn cadans. Als status urenlang op pending blijft staan, roept geen enkele system cron cron.php aan en is de queue vastgelopen. De Adobe Commerce cron reference documenteert het schema volledig.

Drupal

Drupal 7 en 8+ bewaren allebei een system.cron_last-entry in de state-tabel. Query hem om te zien wanneer cron voor het laatst is afgerond:

SELECT name, value
FROM key_value
WHERE collection = 'state' AND name = 'system.cron_last';

Loopt die timestamp uren achter, dan faalt cron stilzwijgend of is hij nooit aan iets externs gekoppeld.

De HTTP-laag is de derde getuige

De meeste shared hosts geven de FTP-gebruiker leesrechten op de ruwe access logs, meestal onder /logs/ of /var/log/apache/ binnen de home directory. Open het bestand van vorige week en grep op de entrypoints die schedulers raken:

grep -E "wp-cron\.php|/cron|cron\.php|run\.php" access.log.1

Je zoekt naar een User-Agent curl of Wget die op een schoon interval een van deze endpoints raakt vanaf één IP. Dat vertelt je dat er een echte system cron bestaat, dat hij op de weblaag wijst, en je kunt de frequentie raden uit de gaten tussen de timestamps. Als de enige hits op wp-cron.php van echte bezoekers-IP's met browser-user-agents komen, is er helemaal geen externe cron en drijft het schema af.

Check ook .htaccess in de docroot op rewrite-regels die cron-endpoints achter schone URL's verstoppen:

RewriteRule ^run-tasks/?$ /cron.php?secret=hunter2 [L]

Die ene regel, die we op meer dan één geërfde site hebben aangetroffen, betekent dat er een publieke URL is die door iets externs gepingd wordt, en dat het secret-token gewoon in de rewrite staat. Als de vorige developer weg is en de cron op een externe uptime monitor draait waar jij nooit op hebt ingelogd, is dit je enige draadje ernaartoe.

Het schema weer in elkaar zetten

Inmiddels heb je drie lijsten. De mtime-sweep vertelt je welke bestanden cron produceert en wanneer. De database-queue vertelt je welke applicatie-jobs geregistreerd staan. De access log vertelt je of er überhaupt iets externs de queue aandrijft. Leg ze naast elkaar.

Wat je doorgaans vindt op een site die drie jaar stilletjes heeft gedraaid is ongeveer dit: één externe curl die elke vijf minuten wp-cron.php of cron.php raakt vanaf een goedkope uptime monitor, een handvol applicatie-jobs geregistreerd door plugins die al lang verwijderd zijn en nu fouten gooien die niemand leest, één of twee backup-scripts die schrijven naar een map die sinds 2022 niet meer is leeggemaakt, en één rewrite-regel die een onauthenticated trigger-endpoint blootlegt.

Wat we voor precies dit moment hebben gebouwd

De reden dat we de vorm van deze audit kennen, is dat we hem ongeveer veertig keer met de hand hebben gedaan voor we er moe van werden. Toen we Pier bouwden belandden we steeds in dezelfde scène: een FTP-account, een MySQL-login, en een developer die om 23:00 op een dinsdag een schema probeert te reconstrueren uit logbestanden. De manier waarop we het uiteindelijk hebben aangepakt was het bestandssysteem en de MySQL editor in hetzelfde venster zetten, zodat de access log en de cron_schedule-tabel naast elkaar staan, en elke wijziging die je in een van beide doet wordt vastgelegd in version history.

Het kleinste wat je vandaag kunt doen: open de access log van een site die je nu beheert en grep op wp-cron.php of cron.php. Als de enige hits van echte browsers komen, heb je net een cron job gevonden die niet draait, en heb je tien minuten om een uptime monitor op te tuigen die dat oplost.

— Vragen —

Kan ik /etc/crontab via FTP lezen?

Op vrijwel elke shared host niet. De FTP-gebruiker zit opgesloten in een home directory. Je moet system crons afleiden uit logbestanden, mtimes van bestanden, en access-log-hits op scheduler-endpoints.

Hoe weet ik of WP-Cron daadwerkelijk op een schema afvuurt?

Grep de access log op hits naar wp-cron.php. Als de enige bezoekers echte browsers zijn en geen curl- of Wget-user-agent op een schoon interval, drijft er niets en stapelen events zich oneindig op.

Waar bewaart Magento zijn geplande jobs?

In de cron_schedule-tabel. Eén rij per geplande run, met status, scheduled_at, executed_at en finished_at. Groepeer op job_code om de cadans te zien en vastgelopen jobs te vinden.