027 —Migration
Custom PHP-intranet van Windows 2008 af in één weekend
Een ongedocumenteerd custom PHP-intranet, een Windows 2008-machine die maandag uit het rack gaat, en één weekend. De inventaris, de breuken, de cutover.
De Loom kwam binnen om 23:41 op een donderdag. Een tweekoppig ops-team bij een logistiek bedrijf waar we mee werken had net te horen gekregen dat hun Windows Server 2008 R2-machine maandagochtend uit het rack zou gaan. De pandeigenaar ontmantelde de communicatieruimte, er kwam geen verlenging, en op die machine stond een custom PHP-intranet, rond 2011 gebouwd, dat elke planner gebruikte om ladingen te loggen, ritlijsten te printen en chauffeurscertificeringen te controleren. Geen leverancier. Geen documentatie. De oorspronkelijke ontwikkelaar was al zes jaar weg. Ze hadden één weekend om het volledig van Windows af te halen.
Dit is een doorloop van die PHP-intranetmigratie van begin tot cutover, inclusief de dingen die Windows tien jaar lang stilletjes had verborgen. Niets hiervan is exotisch. Dat het in een weekend past, komt doordat het werk grotendeels mechanisch is zodra je weet waar de lijken liggen, en de enige stap die data kan verliezen zonder weg terug is de stap die bijna iedereen onderschat.
De vrijdaginventaris
Je kunt geen PHP-intranetmigratie plannen die je niet hebt gemeten. De eerste twee uur waren RDP erin, geen aanpassingen, alleen kijken. De vorm die terugkwam was de vorm van duizend interne apps uit dat tijdperk:
- PHP 5.4 via FastCGI op IIS 7.5, app-root op
C:\inetpub\wwwroot\intranet. - MySQL 5.5, één database, tabellen gedeclareerd als
latin1maar zichtbaar gevuld met UTF-8-bytes. - Authenticatie tegen Active Directory over plain LDAP naar een domeincontroller op het LAN.
- Drie Windows Task Scheduler-taken die op een timer
php.exeaanriepen voor nachtelijke syncs. - Geüploade documenten weggeschreven naar een mapped drive, in code aangeduid als
D:\shares\intranet-docs. - URL-routing afgehandeld door een stapel
rewrite-regels inweb.config.
Microsoft beëindigde de extended support voor Windows Server 2008 R2 in januari 2020, dus niets hiervan was een verrassing. Het punt van de inventaris is geen oordeel, het is de migratiechecklist die zichzelf schrijft. Elk item hierboven is iets dat op Linux en PHP 8 op een specifieke, voorspelbare manier breekt.
Die lijst vastleggen is zelf al deel van het werk, want de helft ervan is onzichtbaar vanuit het bestandssysteem. De geplande taken in het bijzonder zitten helemaal niet in de codebase; ze leven in de Task Scheduler-boom, en de enige veilige manier om ze van een machine te lezen die je gaat verliezen, is ze te exporteren voordat je iets aanraakt:
schtasks /query /fo LIST /v > C:\tasks-dump.txt
schtasks /query /tn "\IntranetSync" /xml > C:\task-sync.xmlDe XML doet er meer toe dan het lijkt. Hij legt vast in welke werkdirectory elke taak draaide, en een verrassende hoeveelheid oude PHP gaat uit van de directory die Task Scheduler toevallig meegaf in plaats van een absoluut pad. Die aanname is de meest voorkomende reden dat een taak die “hetzelfde commando is” op de nieuwe machine stilletjes niets doet.
De Linux-doelmachine opzetten
We zetten een schone Debian 12-VM op infrastructuur waar het team al voor betaalde, en kozen Apache plus PHP-FPM in plaats van nginx. De reden was beperkt en praktisch: de rewrite-logica uit web.config mapt vrijwel regel voor regel op mod_rewrite, en op een klok van 48 uur wil je geen routing-semantiek opnieuw zitten afleiden in een nieuwe engine.
apt update
apt install -y apache2 php8.2 php8.2-fpm php8.2-mysql \
php8.2-ldap php8.2-mbstring php8.2-curl php8.2-gd mysql-server
a2enmod rewrite proxy_fcgi setenvif
a2enconf php8.2-fpm
systemctl restart apache2Twee php.ini-waarden werden ingesteld voordat er code draaide, omdat hun afwezigheid ruis produceert die de echte fouten maskeert: date.timezone = Europe/Amsterdam en display_errors = Off met log_errors = On verwijzend naar een bestand dat we konden tail -f'en. Vanaf PHP 7.0 is een niet-ingestelde timezone een warning bij elke date()-aanroep, en een scherm vol warnings verbergt de ene fatal die er echt toe doet.
Nog één ding voordat er code draait: ownership. IIS draaide de app als een service-identiteit waar al jaren niemand aan had gedacht; op de nieuwe machine draait PHP-FPM als www-data, en de uploaddirectory moet schrijfbaar zijn voor precies die gebruiker en niets ruimer. Een chown -R www-data:www-data /srv/intranet-docs plus een find . -type f -exec chmod 644 {} +-veegactie is dertig seconden werk dat de klasse “het werkt via SSH maar niet in de browser”-verwarring voorkomt die anders een uur opslokt op het slechtst denkbare moment.
De PHP porten die Windows had verborgen
Dit is het deel van elke custom PHP-intranetmigratie dat de meeste tijd opslokt, en bijna alles ervan is een van drie soorten falen.
Hoofdlettergevoeligheid, de stille
Windows maakt het niet uit of je require 'Includes/Config.php' of includes/config.php schreef. Linux maakt het absoluut uit. De eerste pagina-load op de nieuwe machine gaf terug:
PHP Warning: include(includes/Config.php): Failed to open stream:
No such file or directory in /var/www/intranet/bootstrap.php on line 12De oplossing is niet om bestanden willekeurig te hernoemen tot het laadt. Zoek elke include en require, en breng dan de letterlijke string in lijn met de echte bestandsnaam op schijf:
grep -rnoE "(include|require)(_once)?[^;]+" /var/www/intranet \
| grep -iE "\\.php" | sort -uIn deze app waren er elf mismatches, allemaal ontstaan over jaren waarin iemand op Windows de casing-drift niet opmerkte. We pasten de aanroepplekken aan, niet de bestandsnamen, zodat de versiegeschiedenis leesbaar bleef.
Dode extensies en dode functies
Het intranet stond vol met mysql_query(). De ext/mysql-extensie werd in PHP 5.5 deprecated en in PHP 7.0 volledig verwijderd, dus op PHP 8.2 krijg je een harde stop:
PHP Fatal error: Uncaught Error: Call to undefined function mysql_connect()Er waren 340 aanroepplekken. Elk daarvan in een weekend met de hand herschrijven is hoe je om 04:00 SQL-injectie introduceert. In plaats daarvan schreven we een dunne compatibiliteitslaag boven mysqli die de oude signatures behield, lieten die in de bootstrap vallen, en veranderden verder niets:
<?php
// compat/mysql.php — temporary shim, scheduled for removal post-cutover
$GLOBALS['__db'] = null;
function mysql_connect($h, $u, $p) {
$GLOBALS['__db'] = mysqli_connect($h, $u, $p);
return $GLOBALS['__db'];
}
function mysql_select_db($name) {
return mysqli_select_db($GLOBALS['__db'], $name);
}
function mysql_query($sql) {
return mysqli_query($GLOBALS['__db'], $sql);
}
function mysql_fetch_assoc($r) { return mysqli_fetch_assoc($r); }
function mysql_num_rows($r) { return mysqli_num_rows($r); }
function mysql_real_escape_string($s) {
return mysqli_real_escape_string($GLOBALS['__db'], $s);
}
function mysql_error() { return mysqli_error($GLOBALS['__db']); }Een shim is een schuld, geen oplossing, en we logden hem als zodanig. Maar het leverde een werkende app op zaterdagmiddag op in plaats van zondagnacht, wat op een harde deadline het hele spel is. Dezelfde triage gold voor een mcrypt_encrypt()-fatal (de extensie verdween in PHP 7.2): één token-signing-functie herschreven op openssl, de rest met rust gelaten. Hardgecodeerde dirname(__FILE__) . '\\config.php'-padcombinaties werden naar forward slashes geveegd, die Linux accepteert en Windows altijd ook al deed.
De PHP 8-wijzigingen die geen warning geven
Verwijderde functies kondigen zichzelf aan: de pagina sterft met een duidelijke fatal en je gaat het fixen. De migraties die pijn doen, zijn de gedragsmatige, die veranderen wat werkende code doet zonder iets op te werpen waar je op zou denken te grep'en. Twee op deze app waren de tijd waard die ze kostten.
De eerste was mysqli-foutrapportage. Vanaf PHP 8.1 werd de standaard report-modus MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT, wat betekent dat een mislukte query een mysqli_sql_exception gooit in plaats van false te retourneren. Een codebase uit 2011 verwachtte dat nooit; die controleerde returnwaarden met de hand en ging door. Pagina's die jarenlang hadden gestrompeld op een query die stilletjes faalde, stierven nu regelrecht. De oplossing is niet om elke aanroepplek na te jagen maar om één bewuste keuze te maken op de shim-grens:
mysqli_report(MYSQLI_REPORT_OFF); // restore pre-8.1 return-value behaviourDat is ook een schuldpost, opgeschreven naast de shim: de foutafhandeling van de oude code is echt fout, maar een migratieweekend is niet het moment om driehonderd aanroepplekken ervan te herschrijven. De tweede klasse was string- en array-semantiek. Toegang tot strings met accolades ($s{0}) werd in PHP 8.0 verwijderd, en each() en create_function() gingen mee. Dat zijn fatals, dus ze komen snel boven bij een volledige doorklik, wat precies de reden is dat de zaterdagmiddag-doorloop met een planner bestaat: een mens die elk scherm aftikt vindt de fatals die geen enkele statische grep vindt, omdat ze alleen afgaan op die ene rapportage waarvan niemand bedacht had die in de test te draaien.
De data verplaatsen zonder dubbele encoding
De database leek de makkelijkste stap en is degene die migraties stilletjes ruïneert. De tabellen waren gedeclareerd als latin1 maar de applicatie schreef er al jaren UTF-8 in, dus de bytes op schijf waren al correcte UTF-8 die in een kolom zat die beweerde iets anders te zijn.
De verkeerde zet is dumpen met --default-character-set=utf8mb4 en importeren in een utf8mb4-schema. MySQL converteert behulpzaam onderweg naar buiten, en je krijgt het schoolvoorbeeld van dubbele encoding: elke é wordt é, elke ö wordt ö. Het veilige pad is dumpen in de charset waarin de data fysiek was opgeslagen, ongewijzigd importeren, en dan het schema ter plekke converteren zodra de bytes thuis zijn:
# on the Windows box, dump in the charset the bytes actually are
mysqldump --default-character-set=latin1 --skip-set-charset \
-u root -p intranet > intranet.sql
# on Linux, import untouched, then convert each table
mysql -u root -p intranet < intranet.sqlALTER TABLE drivers CONVERT TO CHARACTER SET utf8mb4;
ALTER TABLE loads CONVERT TO CHARACTER SET utf8mb4;
ALTER TABLE certs CONVERT TO CHARACTER SET utf8mb4;Rewrite-regels, cron en Active Directory op de nieuwe machine
De routing uit web.config vertaalde vrijwel mechanisch. Een IIS-regel als deze:
<rule name="Report">
<match url="^report/([0-9]+)/?$" />
<action type="Rewrite" url="report.php?id={R:1}" />
</rule>wordt één regel mod_rewrite in een .htaccess in de app-root:
RewriteEngine On
RewriteRule ^report/([0-9]+)/?$ report.php?id=$1 [L,QSA]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php?route=$1 [L,QSA]De drie Task Scheduler-taken werden drie crontab-regels, met de output naar een log gevangen in plaats van te verdwijnen zoals output van geplande taken pleegt te doen:
*/15 * * * * /usr/bin/php8.2 /var/www/intranet/cron/sync.php \
>> /var/log/intranet-sync.log 2>&1Die redirect is niet cosmetisch. Task Scheduler slokte stdout en stderr op, dus jarenlang kon de nachtelijke sync falen zonder dat iemand het zag; beide streams vangen is de eerste keer dat iemand in dit team de vraag “heeft de run van vannacht gewerkt” kon beantwoorden zonder in te loggen. De andere helft van de fix is het absolute pad. De cron-omgeving heeft vrijwel geen PATH en een home-directory die je niet koos, dus alles wat de oude taak gratis kreeg van zijn Task Scheduler-werkdirectory moet expliciet gemaakt worden, en daarom kreeg sync.php één chdir(__DIR__) bovenaan in plaats van te vertrouwen op waar cron het ook liet vallen.
Active Directory was de ene plek waar we verbeterden in plaats van porten. De oude code deed ldap_connect('dc01') in cleartext op het LAN. De nieuwe machine praat met dezelfde domeincontroller over TLS, wat twee gewijzigde regels is en het verschil tussen credentials over de lijn of niet:
$ds = ldap_connect('ldaps://dc01.corp.local', 636);
ldap_set_option($ds, LDAP_OPT_PROTOCOL_VERSION, 3);Twee gewijzigde regels is de gelukkige versie. Wat bijt is certificaatvertrouwen: ldaps:// betekent dat het PHP-proces nu het certificaat van de domeincontroller valideert, en een interne CA die elke domain-joined Windows-machine impliciet vertrouwt zit niet in de trust store van de Linux-machine. Voeg óf de corporate root toe aan /usr/local/share/ca-certificates en draai update-ca-certificates, óf, als je de CA-bundle echt niet voor maandag kunt krijgen, stel de trust-eis bewust in en schrijf hem op als de schuld die het is, in plaats van een stille ldap_bind-fout midden in de cutover te ontdekken:
// only if the corporate CA cannot be installed in time — logged as debt
ldap_set_option(NULL, LDAP_OPT_X_TLS_REQUIRE_CERT, LDAP_OPT_X_TLS_NEVER);De documentenshare verhuisde van een Windows mapped drive naar een gemount pad, en de enige hardgecodeerde D:\shares\intranet-docs-constante in config.php werd /srv/intranet-docs. Eén regel, want de oorspronkelijke ontwikkelaar had dat tenminste gecentraliseerd.
De cutover, en wat we hielden
Zondagmiddag draaiden we het intranet op de nieuwe machine achter een hosts-file-override op twee laptops, liepen een planner door een volledige dienst met echte taken, en zagen het foutenlog stil blijven. Het DNS A-record klapte om 18:00 om, met een TTL die op de vrijdag al naar 300 seconden was verlaagd, wat het goedkoopste is op deze hele lijst en datgene wat mensen vergeten tot ze om 22:00 op een stale record zitten te wachten. De oude machine bleef aan maar werd met een firewall afgeschermd en read-only gezet, onaangeroerd tot het team twee schone werkdagen had, en verliet daarna op schema het rack.
Het rollback-plan was één zin, geschreven vóór de DNS-wijziging in plaats van erna geïmproviseerd: de oude machine bleef aan, met een firewall alleen open naar de twee ops-laptops, met zijn MySQL read-only vastgezet via SET GLOBAL read_only = ON zodat niets per ongeluk naar de dode kopie kon schrijven en twee uiteenlopende databases achterlaten. Was de nieuwe machine maandagochtend gefaald, dan was het herstel één A-record terugklappen en een cache van 300 seconden leegmaken, geen restore. Een migratie zonder een uitgesproken rollback die iemand in paniek kan uitvoeren, is niet af, die is alleen ongetest.
Toen we Pier bouwden, liepen we steeds tegen exact dit weekend aan: het deel dat een ongedocumenteerde legacy site-migratie eng maakt, is niet het werk, het is niet weten of de wijziging die je net in een live database deed omkeerbaar is. Hoe we het uiteindelijk aanpakten, was elke aanpassing door een version history laten lopen en de MySQL editor dezelfde één-klik-undo geven als de bestanden, zodat een beslissing om 04:00 nooit een eenrichtingsdeur is.
Het kleinste wat je vandaag kunt doen, voordat iemand je een deadline geeft, is twee greps over je eigen intranet draaien: één voor mysql_-aanroepen en korte <?-open-tags, en één die elke include- en require-string tegen de echte bestandsnamen op schijf legt. Die twee outputs zijn het grootste deel van het weekend, en je kunt ze op een rustige dinsdag lezen in plaats van ze op een zaterdag te vinden.
— Vragen —
Kun je een custom PHP-intranet echt in één weekend van Windows Server 2008 af migreren?
Als de app één codebase met één database is, ja. Het werk is mechanisch: hoofdlettergevoelige includes, verwijderde mysql_-functies, charset-afhandeling en het vertalen van rewrite-regels. Het risico zit in de datastap, niet in de tijd.
Waarom raken geaccentueerde tekens dubbel encoded bij het verplaatsen van de MySQL-database?
Tabellen die als latin1 zijn gedeclareerd maar UTF-8-bytes bevatten, worden bij het dumpen geconverteerd. Verifieer eerst met HEX() op de bron, dump in de charset waarin de bytes echt staan, en doe pas na de import ALTER TABLE naar utf8mb4.
Is een compatibiliteitsshim van mysql_ naar mysqli veilig om uit te rollen?
Als tijdelijke maatregel onder deadline wel, mits hij het escape-gedrag behoudt. Behandel hem als gelogde technische schuld en verwijder hem na de cutover. Het is een noodverband, geen oplossing.