Administrer un parc de sites SPIP sans SSH : deux plugins, et un appel à vos avis

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.

1 « J'aime »

Bonjour,
Sans rentrer dans un détail technique qui me dépasse largement, j’ai l’impression que sur certains besoin, il existe déjà des solutions:

Le tout récent autoupdate pour le Core

ou encore Majplugins pour les plugins

Mais j’ai peut-être zappé quelque chose… :wink:

Hello c’est intéressant forcément mais c’est dommage… que le concept existe déjà depuis des années ! Autant pour toi que pour les utilisateurices de repartir de zéro à chaque fois et d’avoir ensuite des doublons de plugins qui font un peu pareil mais pas exactement :slightly_frowning_face:

Il existe déjà depuis des années le plugin Infos site qui fait rigoureusement la même chose comme besoin de base : Plugin « Info Sites » - SPIP-Contrib

  • un site central est le site maître
  • Les autre sites à contrôler dialoguent avec le site maître

Le détail concret des opérations que le système permet n’a que peu d’importance car c’est justement ça qu’il faut améliorer et augmenter collectivement dans le même plugin s’il manque des fonctionnalités. Mais en revanche l’architecture est déjà là, la base principale est déjà là et répond exactement au même besoin.

Donc ce serait plus sympathique d’aider à améliorer collectivement cette base commune plutôt que tout refaire à zéro chacun dans son coin.

Merci pour ton retour Jean-Marc !
Les deux plugins dont tu parles sont effectivement très utiles mais ne visent pas
l’administration centralisée d’un parc de sites SPIP. Néanmoins ils sont des atouts pour tenir à jour des sites SPIP. Autoupdate n’existait pas lorsque j’ai commencé le dev et je suis un peu lent :wink:
Bien à toi

EDIT :

Merci pour le pointeur. J’ai lu les deux plugins, info_sites et info_spip, et tu as raison sur la prémisse : site maître, sites qui dialoguent avec lui, inventaire centralisé. Sur ce point il n’y a rien à discuter, j’aurais dû chercher avant de me lancer.

Là où je vois une divergence qui n’est pas une affaire de fonctionnalités : Info Sites observe, il n’agit pas sur les sites distants. info_spip publie une fiche d’identité, en privé et en XML - c’est ce que dit son README -, et il n’a effectivement aucun répertoire action/. Côté maître, les pages de mise à jour génèrent un script bash à télécharger et à exécuter soi-même : svn up, curl, unzip, rsync --delete. Il n’y a aucun POST dans tout le dépôt, et les seuls appels sortants vont lire des versions disponibles.

C’est parfaitement cohérent, et ça suppose qu’on dispose d’un accès shell sur les serveurs. Mon besoin était exactement l’inverse : des parcs sans aucun accès shell, chez des hébergeurs différents, où le remplacement du noyau et la migration de schéma doivent se faire depuis le navigateur. Ça impose une signature HMAC avec anti-rejeu, des autorisations révocables opération par opération depuis le site géré, et des opérations découpées en étapes qu’une tâche de fond reprend quand la requête meurt en chemin. Aucune requête HTTP ne tient le temps d’une migration derrière un frontal.

Ce sont des choix structurants, pas des fonctions à ajouter : greffer de l’écriture distante sur un outil qui n’écrit rien, ce n’est pas l’étendre.

Cela dit je préfère nettement la complémentarité au doublon, et il y a de quoi faire :

là où Info Sites génère un bash pour les parcs qui ont un shell, il pourrait déclencher un agent pour ceux qui n’en ont pas. Les deux publics ne se recouvrent pas ;
son comparateur multi-CMS et son usage de l’API plugins.spip.net sont meilleurs que ce que j’ai fait, et je suis preneur ;
plusieurs morceaux de mon côté sont autonomes et réutilisables tels quels : le Web Push en PHP sans aucune dépendance, la sauvegarde SQL découpée en tranches pour passer sous la patience d’un frontal, la vérification d’un gzip à plusieurs membres.
Si Teddy Payet est dans le coin, je suis très partant pour en discuter, y compris pour regarder ce qui, chez moi, aurait sa place chez lui.