Deux plugins pour piloter un parc de sites SPIP depuis une seule
interface, en HTTPS signé, sans SSH ni FTP. Présentation, modèle de sécurité,
et demande d’avis.
Bonjour à toutes et à tous,
Je viens vous présenter un outil que j’ai développé pour un besoin très
concret, et sur lequel j’aimerais beaucoup votre regard avant d’aller plus
loin.
Le problème de départ
J’administre plusieurs sites SPIP. Ils ne sont pas chez le même hébergeur et la moitié sont en mutualisé. Résultat : pour savoir si un site était à jour, il fallait s’y connecter. Pour mettre à jour un plugin, s’y connecter. Pour savoir si le core avait pris du retard, s’y connecter. Avec quelques sites c’est fastidieux ; passé la dizaine, on finit par ne plus regarder - et c’est précisément là que les retards s’installent.
Les solutions existantes supposent à peu près toutes un accès système. Je cherchais quelque chose qui fonctionne avec le plus petit dénominateur commun d’un hébergement mutualisé : du PHP, du HTTPS, et rien d’autre.
Ce que ça fait
Deux plugins :
- Tour de contrôle, sur un seul site - celui qui pilote ;
- Tour de contrôle : agent, sur chacun des sites gérés.
Ils dialoguent en HTTPS, chaque requête étant signée en HMAC-SHA256 avec un secret propre à chaque site. Pas de SSH, pas de FTP, pas d’accès à la base des sites gérés.
Depuis l’interface du site pilote, on obtient :
- la vue d’ensemble du parc : version du core, plugins installés et leurs versions, extensions PHP et bibliothèques disponibles ;
- le signalement des mises à jour disponibles, plugins et core, et des bases en attente de migration ;
- la mise à jour à distance des plugins, unitairement ou en lot ;
- la mise à jour du core SPIP, avec retour arrière possible, et la migration de son schéma de base dans la foulée - l’étape que SPIP réclamait jusque-là à la main, site par site ;
- la sauvegarde de la base, rapatriée et téléchargeable ;
- la purge sélective des caches : pages, squelettes, images calculées, CSS/JS, sessions ;
- un journal du parc, toutes opérations et tous sites confondus, filtrable ;
- la lecture du pare-feu SPIP WAF des sites qui l’ont, avec la tendance des blocages sur sept, trente ou quatre-vingt-dix jours ;
- des alertes au webmestre, par courriel et par notification du navigateur (mobile compris), et seulement quand l’état du parc a changé.
Des cases à cocher permettent d’agir sur une partie du parc d’un coup :
synchroniser, vider les caches, relire les catalogues de dépôts, mettre à jour l’agent.
Trois partis pris qui expliquent le reste
Toute mise à jour commence par une sauvegarde, sans exception et sans réglage pour la désactiver. C’était la condition pour que j’ose cliquer.
Une mise à jour est un chantier, pas une requête. Remplacer un noyau et migrer un schéma prend des minutes ; aucune requête HTTP ne tient aussi longtemps, et un frontal type Varnish ou un CDN coupe bien avant. L’opération est donc découpée en étapes enregistrées en base, chacune ne faisant qu’un aller-retour. On voit l’avancement pendant qu’il se produit, un chantier interrompu (onglet fermé, coupure réseau) est repris par une tâche de fond, et un échec dit quelle étape a lâché.
Un silence n’est pas une réponse. Quand un site ne répond pas, on ne sait pas si l’opération a échoué ou si elle a réussi sans qu’on l’apprenne - le cas typique étant le plugin qui se remplace lui-même pendant qu’il répond. L’outil va donc {constater} sur le site plutôt que conclure. Ça paraît un détail ; en pratique c’est ce qui distingue un rapport fiable d’un rapport rassurant.
Lire le pare-feu du parc, sans jamais rapatrier son balisage
Pour les sites qui ont le plugin SPIP WAF, un onglet apparaît sur leur fiche - et seulement là : requêtes bloquées, motifs de blocage, bannissements, journal paginé.
Ce n’est pas la page du plugin recopiée. Ses sections se chargent en AJAX, l’agent rend les {données} et c’est le tableau de bord qui les redessine :
accueillir ici le balisage d’un site qui peut être compromis n’était pas envisageable, et une charge utile enregistrée par le pare-feu est précisément le genre de chose qu’on ne veut pas voir s’exécuter dans l’espace privé qui détient les secrets du parc. Tout est rendu par textContent.
Deux graphiques en donnent la tendance sur sept, trente ou quatre-vingt-dix jours - requêtes bloquées et adresses bloquées -, par site et pour le parc entier. Ils viennent de notre base, remplie à chaque synchronisation : ils s’affichent sans rien demander au site, et surtout le parc garde la mémoire des journées que le WAF, lui, finit par purger. Chart.js est embarqué dans le plugin, jamais appelé sur un CDN.
C’est refusé par défaut, sous une autorisation dédiée.
L’aspect sécurité, parce que c’est la vraie question
Autant le dire franchement : cet outil donne à un site le pouvoir d’écrire du code sur d’autres sites. C’est utile et c’est dangereux, et c’est ce point que j’aimerais le plus vous voir critiquer.
Ce qui est en place :
- signature HMAC-SHA256 couvrant l’opération, ses arguments, l’horodatage et un nonce. Le secret fait 256 bits, n’est jamais transmis, et chaque site a le sien - compromettre un site ne compromet pas les autres ;
- anti-rejeu par nonce à usage unique, où c’est l’échec de l’insertion en base qui détecte le rejeu, pas une lecture préalable : deux requêtes concurrentes portant le même nonce ne peuvent pas passer toutes les deux ;
- HTTPS avec vérification du certificat non contournable. Il n’y a pas de réglage pour l’assouplir, volontairement ;
- chaque opération est révocable depuis le site géré. C’est l’agent qui décide ce qu’il accepte, pas le tour de contrôle qui décide ce qu’il demande. Deux capacités sont refusées par défaut : la mise à jour du core, et la consultation de l’état du serveur ;
- les archives sont validées avant d’être déballées. Une archive piégée peut nommer un de ses fichiers
…/…/config/connect.php: déballée naïvement, elle écrit alors {hors} du dossier prévu, n’importe où sur le site. Chaque entrée est donc contrôlée avant extraction, et tout nom qui remonte l’arborescence, part d’une racine absolue ou contient un octet nul est refusé ; - tout ce qui vient d’un site géré est rendu inerte avant d’atteindre la base du tour de contrôle, et échappé à l’affichage. Un site géré peut être compromis, et son inventaire s’affiche dans l’espace privé de celui qui détient les secrets de tout le parc : c’est un chemin d’attaque qu’il fallait fermer ;
- les identifiants sont masqués par l’agent avant de traverser le réseau - empreintes de mots de passe, clés, jetons, aléas de session, URL de la forme
utilisateur:motdepasse@hôte.
Et ce qui n’est pas couvert, parce qu’une page sécurité qui ne dit que le positif ne sert à rien :
- un tour de contrôle compromis. Il détient les secrets de tout le parc. C’est intrinsèque au principe, et ça dicte la manière de l’héberger : ce site doit être au moins aussi protégé que le plus sensible de ceux qu’il administre ;
- un hébergeur hostile sur un site géré : l’agent s’exécute chez lui ;
- la qualité des mises à jour elles-mêmes. L’outil déploie ce qu’on lui dit de déployer, il ne juge pas du contenu.
Un mot sur une décision que je crois importante : l’outil sait déposer [SPIP Check->Gilles Vincent / spip-check · GitLab] sur un site et l’en retirer, mais il ne le pilote pas. SPIP Check s’autorise sur une session d’administrateur du site géré ; contourner cette autorisation pour le télécommander reviendrait à poser une porte dérobée sur tout le parc. L’agent se borne donc à faire ce que le FTP faisait à la main. Même logique pour le spip_loader.php, que l’outil sait renouveler et retirer - c’est le fichier le plus dangereux d’un site SPIP, il n’a de raison d’être que le jour où l’on s’en sert.
Installation
Le plus simple passe par un dépôt SVP. Sur le site qui pilotera le parc, {Configuration → Plugins → Dépôts → Ajouter un dépôt}, puis :
https://nicaille.github.io/Dashboard-administration-sites-spip/plugins.xml
Ensuite, dans {Gestion des plugins}, " Tour de contrôle " s’installe comme n’importe quel plugin. Le même dépôt sert l’agent : déclarez-le aussi sur les sites gérés, et les mises à jour des deux plugins vous seront proposées normalement.
Puis, pour appairer un site :
- sur le site géré, {Configuration → Dashboard : agent} - générer un secret, cocher les opérations qu’on autorise, noter l’URL de l’agent ;
- sur le tour de contrôle, {Édition → Parc de sites SPIP → Ajouter un site} - coller l’URL et le secret ;
- cliquer sur Synchroniser.
Pré-requis : SPIP 4.1 à 4.4 et PHP 7.4+ des deux côtés, HTTPS valide sur chaque site géré, les extensions zip et zlib sur les sites gérés, et de préférence sodium pour que les secrets soient chiffrés en base.
{Faire travailler SPIP sans visiteurs}
C’est le point qu’on oublie, et il vide l’outil de la moitié de son intérêt quand il manque : SPIP relance sa file de travaux à la fin de chaque visite. Une tour de contrôle que personne ne consulte ne synchronise jamais son parc - et c’est précisément le site dont on attend qu’il travaille tout seul.
Là où l’on dispose d’un vrai cron Unix, spip.php?action=cron suffit. Sur un mutualisé, il n’y en a pas, et le frontal coupe la requête avant qu’une synchronisation de dix sites ait fini. Le plugin fournit donc outils/cron.php, qui amorce SPIP en ligne de commande - aucun frontal, aucun max_execution_time de serveur web. Il vit dans l’espace web, puisque c’est la seule chose qu’une tâche planifiée d’hébergeur sait exécuter, et refuse donc tout autre contexte que la ligne de commande : atteint par un navigateur, il rend 404 et ne fait rien.
Un détail qui a son importance chez certains hébergeurs - OVH mutualisé notamment : leur tâche planifiée n’accepte qu’un chemin de fichier, sans le moindre argument. Les options se posent donc dans un fichier cron.conf déposé à côté :
duree=300
tours=200
# verbeux
duree est le réglage qui compte quand l’hébergeur ne descend pas sous l’heure : le script « attend » l’échéance du prochain travail tant qu’elle tient dans ce budget, au lieu de rendre la main. Avec cinq minutes de budget et une tâche déclarée toutes les deux minutes, un déclenchement horaire donne trois passages au lieu d’un.
tours borne le nombre d’appels, parce qu’un tour qui ne trouve rien à faire ne coûte rien et que le temps ne le bornerait donc pas.
verbeux fait raconter le passage sur la sortie d’erreur - utile une fois, à l’installation, pour vérifier que la tâche part vraiment ; à recommenter ensuite si l’hébergeur poste les rapports par courriel.
Où héberger la tour de contrôle
Ce site détient les secrets de tout le parc. Sa surface d’attaque doit donc être la plus petite possible, et ce que je conseille tient en trois points :
- un site SPIP dédié, qui ne sert qu’à ça et n’a aucun public ;
- couvert par un htpasswd, ce qu’un site d’administration peut se permettre sans rien perdre ;
- avec le minimum de plugins : « Facteur » pour un envoi de courriel fiable en SMTP, de quoi consulter les journaux confortablement, SVP - livré avec SPIP, et c’est lui qui apporte les mises à jour par le dépôt -, et la tour de contrôle. Rien d’autre.
Une conséquence du htpasswd à connaître : il tue le cron par HTTP, puisque spip.php?action=cron reçoit un 401 avant que PHP ne s’exécute. Deux réponses, qui se cumulent bien - la constante _DIRECT_CRON_FORCE dans config/mes_options.php, qui fait jouer la file à la fin de chaque visite, et le outils/cron.php ci-dessus, qui ne passe pas par le réseau du tout.
Et si vous mettez la tour dans son propre parc pour qu’elle se mette à jour elle-même, le htpasswd ne gêne pas : la fiche d’un site porte des champs {utilisateur} et {mot de passe HTTP} faits pour ça. Les identifiants ouvrent la porte, la signature authentifie l’appel - ce sont deux choses distinctes, et le
code les traite comme telles.
Bien l’exploiter : trois conseils tirés de l’usage
Commencez petit et n’autorisez que le nécessaire. Les capacités sensibles sont refusées par défaut, site par site. Un parc où seule la lecture est ouverte rend déjà l’essentiel du service - savoir où l’on en est - sans aucun pouvoir d’écriture.
Relisez les catalogues de dépôts avant de conclure quoi que ce soit. Le catalogue des versions disponibles vit sur le site géré et ne se rafraîchit pas tout seul. Sans cette relecture, le parc annonce " à jour " des sites qui ne le sont pas - c’est un travers que j’ai constaté en vrai, et qui a coûté une semaine avant d’être compris. La vue d’ensemble date donc le plus ancien catalogue du parc : c’est lui qui borne la confiance qu’on peut accorder au total affiché.
Activez les alertes, mais sachez ce qu’elles taisent. Le passage quotidien n’écrit que s’il y a du nouveau : un récapitulatif identique tous les jours est un récapitulatif qu’on cesse d’ouvrir, et le jour où il porte enfin quelque chose, personne ne le lit. Corollaire : pas de courriel ne veut pas dire pas de passage. Tout est éteint par défaut, liste de destinataires comprise.
Ce sur quoi j’aimerais votre avis
C’est vraiment le but de ce message. En particulier :
- le modèle de sécurité vous paraît-il tenable ? Le point qui me fait le plus réfléchir reste la concentration des secrets sur le tour de contrôle ;
- le choix de deux plugins plutôt qu’un seul avec un mode : est-ce le bon découpage ?
- les conventions SPIP que j’aurais pu manquer. J’ai essayé de m’en tenir à l’habillage du privé et aux idiomes du core plutôt que de redéfinir par-dessus ;
- et, tout simplement, est-ce que ça répond à un besoin que vous avez ? Je l’ai écrit pour le mien, et je serais curieux de savoir s’il ressemble au vôtre.
Le code, la documentation - architecture, protocole d’API, sécurité, exploitation - et les tests sont ici :
https://github.com/nicaille/Dashboard-administration-sites-spip
Merci d’avance pour vos retours, même sévères. Ils seront utiles.