From 5356625a26b2477fd113078598c14dcf47f230c7 Mon Sep 17 00:00:00 2001 From: "Fabien F." Date: Fri, 31 Jul 2026 12:42:33 +0200 Subject: [PATCH 1/4] docs(spec): installer un poste en une commande --- ...-31-installation-en-une-commande-design.md | 215 ++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-31-installation-en-une-commande-design.md diff --git a/docs/superpowers/specs/2026-07-31-installation-en-une-commande-design.md b/docs/superpowers/specs/2026-07-31-installation-en-une-commande-design.md new file mode 100644 index 0000000..ae8312f --- /dev/null +++ b/docs/superpowers/specs/2026-07-31-installation-en-une-commande-design.md @@ -0,0 +1,215 @@ +# Installer un poste en une commande + +> Conception validée le 31/07/2026. La référence tenue à jour reste +> `docs/02-architecture.md` (§15.2, §17.2). Ce document décrit le chemin d'entrée dans +> l'installation, pas l'installation elle-même : celle-ci ne change pas. + +## Le problème + +Installer un poste demande aujourd'hui sept gestes, dont quatre n'ont rien à voir avec +OpenScale : trouver l'archive, la copier depuis une clé, cocher « Débloquer » dans les +propriétés du fichier, extraire, ouvrir PowerShell **en administrateur**, se placer dans le +bon répertoire, lancer `install.ps1`. `INSTALLATION.md` y consacre ses deux premières +étapes et cinq minutes sur les dix-sept annoncées. + +Trois de ces gestes échouent en silence chez un bénévole : + +- la case **« Débloquer »** oubliée fait refuser `install.ps1` par la stratégie + d'exécution, avec un message qui parle de « fichier téléchargé depuis Internet » et + jamais d'OpenScale ; +- **PowerShell ouvert sans élévation** fait sortir le script sur son propre refus, à la + ligne 68 — le message est bon, mais il arrive après le téléchargement et l'extraction ; +- **le mauvais répertoire** produit « openscale.exe est introuvable à côté de + install.ps1 », qui accuse l'archive alors que c'est le `cd` qui a manqué. + +Ce document décrit une commande unique, sur le modèle de l'installation de Claude Code, qui +fait les sept gestes et pose les trois questions qui appartiennent vraiment à l'humain. + +## Ce qui existe déjà, et qu'on ne réécrit pas + +| Pièce | Ce qu'elle apporte | +|---|---| +| `deploy/windows/install.ps1` | Toute l'installation : compte local, ACL, ouverture de session automatique, service, tâche du kiosque, alimentation, Windows Update, fiche d'installation. Idempotent, éprouvé sur poste réel | +| `.github/workflows/release.yml` | `openscale-vX-windows-amd64.zip` et `SHA256SUMS-archives.txt` publiés sur un tag | +| `internal/domain/config.go` | `DefaultUpdateRepository = "lostmind84/OpenScale"` | +| `internal/update/github.go` | `DefaultBaseURL = "https://api.github.com"`, et le contrat de `/releases/latest` : ni brouillon ni pré-version | +| `deploy/deploy_test.go` | 32 tests qui lisent les scripts : parsing PowerShell réel, marque d'ordre des octets, appels natifs gardés, ordre des étapes de §15.2 | + +**`install.ps1` n'est pas réécrit.** Le script d'amorçage l'appelle. C'est ce qui garde une +seule description de l'installation, et laisse la voie hors ligne entière : un poste sans +Internet s'installe toujours depuis une clé USB, exactement comme aujourd'hui. + +## La commande + +```powershell +irm https://raw.githubusercontent.com/lostmind84/OpenScale/main/deploy/windows/bootstrap.ps1 | iex +``` + +```cmd +curl -fsSL https://raw.githubusercontent.com/lostmind84/OpenScale/main/deploy/windows/bootstrap.cmd -o %TEMP%\openscale.cmd && %TEMP%\openscale.cmd +``` + +**L'URL pointe `main`, et pas une release.** Le fichier ne porte aucun numéro de version : +il demande la dernière à l'API. Le publier comme actif de release le figerait sur le tag qui +l'a produit, et corriger l'amorçage imposerait alors une release ; le publier sur GitHub +Pages en ferait une seconde copie à tenir synchrone avec `deploy/windows/`. Un seul fichier, +versionné là où il vit, corrigeable en un commit. + +**Le poste de pesée est hors ligne par conception** (contrainte 4, citée par `harden.ps1`). +La commande suppose donc un accès Internet **au moment de l'installation seulement**, et la +voie clé USB reste documentée dans `INSTALLATION.md` pour le poste qui n'en a pas. + +## Ce que fait `bootstrap.ps1` + +```mermaid +flowchart TD + A[Contrôles : Windows, amd64, PowerShell 5.1+, TLS 1.2] --> B{Administrateur ?} + B -- non, interactif --> C[Se copie dans TEMP
Start-Process -Verb RunAs] + C --> D[Nouvelle fenêtre élevée] + B -- non, -SessionPassword fourni --> E[Refus : console élevée exigée] + B -- oui --> F + D --> F[GET api.github.com/releases/latest] + F --> G[Télécharge le zip et SHA256SUMS-archives.txt] + G --> H{Empreinte conforme ?} + H -- non --> I[Arrêt : rien n'est extrait] + H -- oui --> J[Expand-Archive puis Unblock-File -Recurse] + J --> K[Les trois questions] + K --> L[install.ps1, dans le MÊME processus] + L --> M[Dossier conservé sous ProgramData OpenScale installer version] +``` + +Cinq points portent la conception, et le reste en découle. + +### L'élévation et le mode non-interactif ne se combinent pas + +Le one-liner est tapé dans une console non élevée neuf fois sur dix. Le script s'écrit donc +dans `%TEMP%` et se relance par `Start-Process -Verb RunAs` : une invite UAC, puis une +nouvelle fenêtre où se déroulent les questions et l'installation. + +**Avec `-SessionPassword`, cette relance est refusée.** Relancer une fenêtre élevée fait +passer les paramètres par une ligne de commande, où n'importe quel utilisateur de la machine +lit le mot de passe dans la liste des processus. Le mode scripté exige donc une console déjà +élevée, et le dit. Ce n'est pas une limite technique qu'on lèvera plus tard : c'est le choix +de ne jamais écrire un secret dans un `argv`. + +### L'empreinte est vérifiée avant l'extraction, pas après + +`Expand-Archive` sur une archive non vérifiée écrit des fichiers sur le disque avant qu'on +sache d'où ils viennent. L'ordre est donc : télécharger le zip, télécharger +`SHA256SUMS-archives.txt`, comparer, **puis seulement** extraire. Un test le vérifie sur le +texte du script, parce que c'est un ordre que rien d'autre ne rappelle à qui édite le +fichier. + +### `Unblock-File` est appelé, et c'est l'étape 1 d'`INSTALLATION.md` qui disparaît + +Tout fichier extrait d'une archive téléchargée porte la marque de zone Internet. Sans +`Unblock-File -Recurse` sur le dossier extrait, `install.ps1` est refusé par la stratégie +d'exécution — le geste que la notice décrit aujourd'hui comme « clic droit → Propriétés → +Débloquer », et que personne ne fait du premier coup. + +### `install.ps1` est appelé dans le même processus + +Le mot de passe de session ne transite ni par une ligne de commande, ni par un fichier, ni +par une variable d'environnement : le script appelle `install.ps1` avec un `[securestring]`, +dans le processus élevé où il vient d'être saisi. + +### Le dossier extrait survit à l'installation + +`install.ps1` copie le binaire, la configuration livrée et les deux notices dans +`Program Files`. **Il ne copie aucun script.** Aujourd'hui `uninstall.ps1`, `update.ps1` et +`harden.ps1` survivent parce que l'archive reste sur le Bureau ; un amorçage qui nettoierait +`%TEMP%` laisserait un poste **sans désinstalleur**. + +Le dossier extrait est donc déplacé, après succès, vers +`C:\ProgramData\OpenScale\installer\\`, et le chemin est affiché à la fin. C'est +aussi ce que `TROUBLESHOOTING.md` demande de retrouver quand il dit « relancez +`install.ps1` ». + +## Les trois questions + +``` +Mot de passe de la session Windows « openscale » + 4 caractères minimum. Vide = tiré au sort (20 caractères). + Il sera imprimé sur la fiche d'installation. +> **** +> **** (confirmation) + +Type d'installation + [1] Production — le poste démarre seul (défaut) + [2] Pilote — service en démarrage manuel, l'application Access reste relançable +> 1 + +Ouverture de session automatique après une coupure de courant ? [O/n] +> O +``` + +Les répertoires d'installation ne sont pas demandés : `-InstallDir` et `-DataRoot` restent +des paramètres, pour le poste dont le disque système n'est pas `C:`, et ne méritent pas une +question posée aux quatre postes de la coopérative. + +| Question | Paramètre | Défaut | +|---|---|---| +| Mot de passe de session | `-SessionPassword` (`[securestring]`) | tiré au sort, 20 caractères | +| Production ou pilote | `-Pilot` | production | +| Ouverture de session automatique | `-SkipAutoLogon` | activée | +| — | `-Yes` | pose zéro question, prend les défauts | +| — | `-Version` | dernière release | +| — | `-InstallDir`, `-DataRoot` | ceux de `Get-OpenScalePaths` | + +Fourni = pas demandé. **Sans console interactive et sans `-Yes`, le script échoue** au lieu +de bloquer sur une invite que personne ne voit. + +## Le mot de passe de session à quatre caractères + +Le mot de passe du compte Windows `openscale` est déjà écrit **en clair** dans +`Winlogon\DefaultPassword` — `install.ps1` l'y met lui-même, et l'assume en commentaire : sur +un poste en libre-service, l'accès physique vaut déjà l'accès administrateur. Le raccourcir +n'aggrave donc rien pour qui touche le poste ou en est administrateur. + +Ce que ça change est l'accès **réseau** : un compte local Windows est joignable en SMB, et +un mot de passe de quatre caractères tombe en quelques secondes depuis n'importe quel PC du +magasin. La parade — refuser à ce compte `SeDenyNetworkLogonRight` et +`SeDenyRemoteInteractiveLogonRight` — a été proposée et **écartée le 31/07/2026** : le +réseau du magasin est un réseau de confiance et personne ne s'y connecte. C'est un choix +assumé, écrit ici pour qu'il soit rouvrable et non redécouvert. + +Deux gardes restent nécessaires côté `install.ps1` : + +1. La stratégie locale peut imposer une longueur ou une complexité minimale (`net + accounts`). Hors domaine elle vaut zéro par défaut, donc quatre passe — mais quand elle + refuse, `New-LocalUser` lève une exception .NET qui ne nomme pas la cause. Le script la + nomme. +2. La **longueur** et l'**origine** du mot de passe sont écrites dans `install.log` + (« mot de passe de session : 4 caractères, fourni »), jamais sa valeur. La valeur va sur + la fiche d'installation, qui part au classeur. + +## Ce que devient `install.ps1` + +Un seul ajout : `[securestring]$SessionPassword`. Absent, `New-RandomPassword 20` comme +aujourd'hui. Les 350 lignes éprouvées sur poste réel ne bougent pas. + +## Les tests + +Dans `deploy/deploy_test.go`, à côté des 32 existants. Le parsing PowerShell réel et la +marque d'ordre des octets couvrent le nouveau fichier sans une ligne de plus. + +| Test | Ce qu'il empêche | +|---|---| +| Aucun numéro de version en dur dans le script | un amorçage qui installerait éternellement la v0.9 | +| L'empreinte est comparée avant `Expand-Archive` | une archive écrite sur le disque avant d'être vérifiée | +| `Unblock-File` est appelé sur le dossier extrait | le refus de la stratégie d'exécution, redécouvert sur place | +| Tout paramètre passé à `install.ps1` y est déclaré | un `-SessionPassword` que l'installeur ignorerait en silence | +| Le mot de passe n'est dans aucun `Write-Host` ni journal | un secret dans `install.log`, qui reste sur le poste | +| `bootstrap.cmd` et `bootstrap.ps1` nomment la même URL | deux entrées qui divergent | +| Le dépôt interrogé est `DefaultUpdateRepository`, l'hôte est `DefaultBaseURL` | un troisième endroit qui épelle `lostmind84/OpenScale` | +| Le minimum est 4 et la confirmation est demandée | un mot de passe saisi de travers, sur un poste dont on ne peut plus ouvrir la session | +| L'auto-relève est refusée quand `-SessionPassword` est fourni | un secret dans une ligne de commande | + +## La documentation + +- **`INSTALLATION.md`** : les étapes 1 et 2 fusionnent en une commande. La voie clé USB + reste, sous un titre qui dit quand elle sert — poste sans Internet. Le tableau des durées + est recalculé : `TestTheFifteenMinutesAreCountedAndNotClaimed` vérifie que le total est la + somme de ses lignes. +- **`README.md`** et **`handbook/getting-started.md`** : la commande unique, et le renvoi à + `INSTALLATION.md` par URL absolue depuis le handbook (ODR-0002). From 7a0b6c0d602d9efe24d348fcaf05f023e2ac5ad1 Mon Sep 17 00:00:00 2001 From: "Fabien F." Date: Fri, 31 Jul 2026 12:58:04 +0200 Subject: [PATCH 2/4] feat(installation): un poste s'installe en une commande, plus en sept gestes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Installer un poste demandait de trouver l'archive, la copier, cocher « Débloquer » dans les propriétés du fichier, extraire, ouvrir PowerShell en administrateur, se placer dans le bon répertoire, puis lancer install.ps1. Trois de ces gestes échouaient en silence chez un bénévole : la case oubliée, que la stratégie d'exécution transforme en refus parlant de « fichier téléchargé depuis Internet » et jamais d'OpenScale ; la console non élevée, dont le refus n'arrivait qu'après le téléchargement ; et le mauvais répertoire, qui faisait accuser l'archive. deploy/windows/bootstrap.ps1 les fait tous. Il vit sur main et ne nomme aucune version : il demande /releases/latest, seul point de l'API qui exclut brouillons et pré-versions par contrat. L'empreinte est vérifiée AVANT l'extraction, jamais après — décompresser une archive non vérifiée écrit sur le disque des fichiers dont la ligne suivante en exécute un en administrateur. Trois questions au lieu de zéro paramètre : mot de passe de la session, type d'installation, ouverture de session automatique. Le mot de passe ne sort pas du processus — install.ps1 le reçoit en [securestring] — et l'auto-élévation est refusée quand -SessionPassword est fourni, parce qu'une relance élevée ferait passer le secret par une ligne de commande, lisible dans la liste des processus. Deux trous rebouchés en chemin. install.ps1 ne copie aucun script : un poste installé depuis %TEMP% n'aurait eu ni désinstalleur ni script de mise à jour, le dossier extrait est donc conservé sous ProgramData\OpenScale\installer\. Et -Version, qui installe une release antérieure, aurait fait échouer l'appel sur un paramètre inconnu après les trois questions : les paramètres sont confrontés à ce qu'install.ps1 déclare. Le plancher du mot de passe est à quatre caractères. Ce mot de passe est déjà en clair dans Winlogon\DefaultPassword et sur la fiche d'installation : sa longueur ne protège pas ce poste. Le refus de connexion réseau (SeDenyNetworkLogonRight) a été proposé et écarté, le réseau du magasin étant de confiance. INSTALLATION.md retombe à 15 minutes comptées, et la voie clé USB reste écrite pour le poste hors ligne. 12 tests de deploy/ ajoutés aux 32 existants. --- INSTALLATION.md | 181 +++++++------- README.md | 34 ++- SUIVI.md | 7 +- deploy/bootstrap_test.go | 271 +++++++++++++++++++++ deploy/windows/bootstrap.cmd | 23 ++ deploy/windows/bootstrap.ps1 | 440 +++++++++++++++++++++++++++++++++++ deploy/windows/common.ps1 | 28 +++ deploy/windows/install.ps1 | 47 +++- handbook/getting-started.md | 19 ++ 9 files changed, 951 insertions(+), 99 deletions(-) create mode 100644 deploy/bootstrap_test.go create mode 100644 deploy/windows/bootstrap.cmd create mode 100644 deploy/windows/bootstrap.ps1 diff --git a/INSTALLATION.md b/INSTALLATION.md index 7ea88c4..0e5ed40 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -6,38 +6,15 @@ de passe d'administrateur Windows, et lire ce qui s'affiche. **Ce dont vous avez besoin avant de commencer :** -- le PC du poste, la balance branchée en USB, l'imprimante d'étiquettes branchée et - allumée, avec un rouleau ; -- l'archive `openscale-2.0.0-windows-amd64.zip` sur une clé USB — **si vous ne l'avez - pas, voir juste en dessous** ; +- le PC du poste, **branché à Internet le temps de l'installation**, la balance branchée + en USB, l'imprimante d'étiquettes branchée et allumée, avec un rouleau ; - **une étiquette imprimée par l'ancienne application**, pour comparer ; - un compte administrateur sur ce PC ; - une imprimante ordinaire pour imprimer la fiche d'installation. À défaut, un stylo. -> ### D'où vient l'archive, et qui la fabrique -> -> **Vous n'avez rien à construire pour installer un poste.** L'archive est préparée une -> fois par la personne qui suit le logiciel, et c'est elle que vous copiez sur la clé USB -> pour les quatre postes. Si on vous l'a remise, passez à l'étape 1. -> -> **Si vous devez la fabriquer vous-même**, il faut le dépôt et Go 1.26.5 — c'est le seul -> moment où un outil de développement est nécessaire, et cela n'a lieu qu'une fois par -> version : -> -> ``` -> git clone https://github.com/lostmind84/OpenScale.git -> cd OpenScale -> git tag -a 2.0.0 -m "Version 2.0.0" # le nom de l'archive vient de ce tag -> pwsh -File ./make.ps1 release # sous Linux ou macOS : make release -> ``` -> -> Les trois archives apparaissent dans `dist/`, une par plateforme. Prenez -> `openscale-2.0.0-windows-amd64.zip`. Le détail est dans -> [`README.md`](README.md#déployer). -> -> **Sans le tag**, l'archive porte le numéro de révision du dépôt au lieu de la version — -> par exemple `openscale-473ebed-windows-amd64.zip`. Elle s'installe aussi bien, mais -> personne ne saura dire six mois plus tard ce qu'elle contenait. +Vous n'avez **rien à télécharger et rien à décompresser** : la commande de l'étape 1 s'en +charge. **Si le poste n'a pas Internet**, tout se fait depuis une clé USB — voir +« Installer un poste sans Internet », plus bas. --- @@ -45,18 +22,16 @@ de passe d'administrateur Windows, et lire ce qui s'affiche. | Étape | Durée | Ce que vous faites | |---|---|---| -| 1 | 2 min | Décompresser l'archive et débloquer les fichiers | -| 2 | 3 min | Lancer `install.ps1` en administrateur | -| 3 | 3 min | **Redémarrer et vérifier que le poste revient seul sur l'écran client** | -| 4 | 2 min | Poser le mot de passe d'administration avec le code de secours de la fiche | -| 5 | 1 min | Balance → *Détecter automatiquement* | -| 6 | 4 min | Imprimante → étiquette de test, superposer, régler le décalage | -| 7 | 2 min | Catalogue → source, numéro de poste, *Importer maintenant* | - -**Total : 17 minutes** pour le premier poste. - -**C'est deux minutes de plus que les 15 minutes annoncées, et c'est dit ici plutôt que -découvert sur place.** L'étape qui dépasse est l'étape 6 : superposer une étiquette +| 1 | 3 min | Coller **une commande** dans PowerShell et répondre à trois questions | +| 2 | 3 min | **Redémarrer et vérifier que le poste revient seul sur l'écran client** | +| 3 | 2 min | Poser le mot de passe d'administration avec le code de secours de la fiche | +| 4 | 1 min | Balance → *Détecter automatiquement* | +| 5 | 4 min | Imprimante → étiquette de test, superposer, régler le décalage | +| 6 | 2 min | Catalogue → source, numéro de poste, *Importer maintenant* | + +**Total : 15 minutes** pour le premier poste. + +L'étape la plus longue est l'étape 5, et elle le restera : superposer une étiquette neuve sur une étiquette de l'ancienne application et régler le décalage au dot près demande trois ou quatre essais, et personne ne le fait en une minute la première fois. Les deux autres postes vont plus vite (voir « Les postes suivants », **environ @@ -70,39 +45,45 @@ d'impression de l'imprimante d'étiquettes si elle n'est pas déjà installée --- -## Étape 1 — Décompresser et débloquer (2 min) +## Étape 1 — Une commande (3 min) -1. Copiez le fichier `.zip` de la clé USB vers le Bureau. -2. Clic droit sur le fichier → **Propriétés**. Si vous voyez une case - **« Débloquer »** en bas, cochez-la et validez. Windows marque comme « venant - d'Internet » tout fichier arrivé par une clé, et refuse ensuite de lancer les - scripts qu'il contient. -3. Clic droit → **Extraire tout**. Extrayez sur le Bureau. - -**Si Windows affiche un écran bleu « Windows a protégé votre ordinateur » -(SmartScreen)** en lançant le programme : c'est normal, le binaire n'est pas signé par -un certificat commercial. Cliquez sur **Informations complémentaires**, puis sur -**Exécuter quand même**. Si vous préférez tout débloquer d'un coup, ouvrez PowerShell -dans le dossier extrait et tapez : +Ouvrez le menu Démarrer, tapez `powershell`, ouvrez **Windows PowerShell** — inutile de +faire un clic droit, la commande demandera elle-même les droits qu'il lui faut. Collez +ceci et validez : ```powershell -Get-ChildItem -Recurse | Unblock-File +irm https://raw.githubusercontent.com/lostmind84/OpenScale/main/deploy/windows/bootstrap.ps1 | iex ``` -## Étape 2 — Lancer l'installation (3 min) - -1. Ouvrez le menu Démarrer, tapez `powershell`, **clic droit** sur *Windows PowerShell* - → **Exécuter en tant qu'administrateur**. Répondez *Oui* à la demande de Windows. -2. Placez-vous dans le dossier extrait et lancez l'installation : +
+Depuis une invite de commandes (cmd) plutôt que PowerShell -```powershell -cd "$env:USERPROFILE\Desktop\openscale-2.0.0-windows-amd64" -.\install.ps1 +```cmd +curl -fsSL https://raw.githubusercontent.com/lostmind84/OpenScale/main/deploy/windows/bootstrap.cmd -o %TEMP%\openscale.cmd && %TEMP%\openscale.cmd ``` -Le script parle français et dit ce qu'il fait, ligne par ligne. Il : +
+ +**Windows demande l'autorisation d'administrateur**, et une nouvelle fenêtre s'ouvre : +c'est dans celle-là que la suite se passe. Répondez *Oui*. + +La commande télécharge la dernière version, **vérifie son empreinte**, la décompresse, et +vous pose **trois questions** : -- **sauvegarde** tous les réglages Windows qu'il va modifier, pour pouvoir les remettre +1. **Le mot de passe de la session Windows du poste.** Quatre caractères au minimum, tapé + deux fois. Si vous validez sans rien taper, il est tiré au sort — c'est très bien, il + sera imprimé sur la fiche d'installation. +2. **Production ou pilote.** Répondez *1* (production), sauf si on vous a demandé que + l'ancienne application reste utilisable : dans ce cas *2*, et le poste ne prendra pas + le port série à chaque démarrage. +3. **L'ouverture de session automatique.** Répondez *oui*, sauf si ce poste n'est pas en + libre-service : c'est elle qui fait revenir l'écran client tout seul après une coupure + de courant. + +Puis l'installation se déroule seule. Elle parle français et dit ce qu'elle fait, ligne +par ligne. Elle : + +- **sauvegarde** tous les réglages Windows qu'elle va modifier, pour pouvoir les remettre le jour où vous désinstallerez ; - crée un compte Windows dédié, `openscale`, **sans droits administrateur** ; - installe le poste comme **service Windows** : il démarre avant toute ouverture de @@ -114,17 +95,45 @@ Le script parle français et dit ce qu'il fait, ligne par ligne. Il : - interdit à Windows Update de redémarrer le PC entre 7 h et 21 h ; - écrit la **fiche d'installation**. -À la fin, il affiche trois choses à faire. **Faites-les dans l'ordre.** +À la fin, elle affiche trois choses à faire. **Faites-les dans l'ordre.** Elle vous donne +aussi le dossier où les scripts du poste sont rangés, +`C:\ProgramData\OpenScale\installer\` : c'est là que vivent la mise à jour et la +désinstallation, et c'est là que ce document vous renverra. -> **Si le script s'arrête sur un message rouge**, lisez-le : il nomme ce qui a échoué. -> Le plus fréquent est *« doit être lancé en ADMINISTRATEUR »* — reprenez au point 1. -> Rien n'est à moitié installé : le script refuse avant d'écrire. +> **Si la commande s'arrête sur un message rouge**, lisez-le : il nomme ce qui a échoué. +> Rien n'est à moitié installé — l'archive est vérifiée avant d'être décompressée, et +> l'installation refuse avant d'écrire. +> +> **Si Windows affiche un écran bleu « Windows a protégé votre ordinateur » +> (SmartScreen)** : c'est normal, le binaire n'est pas signé par un certificat +> commercial. Cliquez sur **Informations complémentaires**, puis **Exécuter quand même**. + +### Installer un poste sans Internet + +Le poste de pesée est **hors ligne par conception** : il n'a besoin du réseau qu'à +l'installation et aux mises à jour. Un poste qui n'y a pas droit s'installe depuis une clé +USB, et rien de ce qui suit ne change. + +1. Depuis un PC connecté, téléchargez `openscale--windows-amd64.zip` sur la + [page des versions](https://github.com/lostmind84/OpenScale/releases/latest) et copiez-le + sur la clé. +2. Sur le poste : copiez le `.zip` sur le Bureau, **clic droit → Propriétés → cochez + « Débloquer »**, puis clic droit → **Extraire tout**. +3. Ouvrez PowerShell **en administrateur** (clic droit sur *Windows PowerShell* → + *Exécuter en tant qu'administrateur*), placez-vous dans le dossier extrait et lancez + l'installation — c'est exactement ce que fait la commande de l'étape 1, une fois + l'archive sur place : -**Pendant la période pilote**, si on vous a demandé que l'ancienne application reste -utilisable, lancez plutôt `.\install.ps1 -Pilot` : le service ne démarrera pas tout -seul, et vous le démarrerez à la demande. +```powershell +cd "$env:USERPROFILE\Desktop\openscale--windows-amd64" +.\install.ps1 +``` -## Étape 3 — La recette obligatoire : redémarrer (3 min) +`install.ps1` ne pose aucune question : le mot de passe de la session est alors tiré au +sort et imprimé sur la fiche. Pour le choisir, ajoutez `-SessionPassword (Read-Host 'Mot +de passe' -AsSecureString)`. Pour la période pilote, ajoutez `-Pilot`. + +## Étape 2 — La recette obligatoire : redémarrer (3 min) **Ne sautez pas cette étape.** C'est la seule preuve que le poste se relèvera d'une coupure de courant, et c'est la panne la plus coûteuse du poste : le PC redémarre, reste @@ -146,7 +155,7 @@ passe, et le poste est inutilisable alors que tout va bien à l'intérieur. > recommence, voir TROUBLESHOOTING.md, « Après un redémarrage, le poste reste sur > l'écran de connexion de Windows ». -## Étape 4 — Le mot de passe d'administration (2 min) +## Étape 3 — Le mot de passe d'administration (2 min) **Prenez la fiche d'installation** : l'installeur y a imprimé un **code de secours de 8 caractères**. Il n'est écrit nulle part ailleurs — le poste n'en garde qu'une empreinte @@ -168,7 +177,7 @@ réglage qui fait apparaître la question. puis touchez **Poser ce mot de passe**. Prenez-en un que l'équipe connaît, pas celui de votre boîte mail. Huit caractères au minimum. 4. **Le geste que vous veniez de faire repart tout seul** : la détection de la balance se - lance sans que vous ayez à retoucher le bouton. L'étape 5 est déjà commencée. + lance sans que vous ayez à retoucher le bouton. L'étape 4 est déjà commencée. 5. **Rangez la fiche dans le classeur du magasin.** Gardez le code : il n'y a pas de « mot de passe oublié » sur un poste hors ligne. **Attention** : une fois un mot de passe posé, ce code ne se saisit plus à l'écran — l'écran ne le redemande qu'à un @@ -176,7 +185,7 @@ réglage qui fait apparaître la question. ci-dessous. > **Le poste affiche encore « Poste hors service » à ce stade, et c'est normal** : sa -> configuration est incomplète tant que les étapes 5 à 7 n'ont pas été faites. Il revient +> configuration est incomplète tant que les étapes 4 à 6 n'ont pas été faites. Il revient > en service tout seul, sans redémarrage, dès qu'il ne reste plus une seule faute. > **Si la fiche a été perdue avant d'avoir servi**, ou **si le mot de passe est perdu @@ -189,10 +198,10 @@ réglage qui fait apparaître la question. > Start-Service OpenScale > ``` -## Étape 5 — La balance (1 min) +## Étape 4 — La balance (1 min) 1. Page **Matériel**, encadré **Balance** → **Détecter automatiquement**. C'est le geste - que l'étape 4 vient de lancer : s'il tourne encore, laissez-le finir. + que l'étape 3 vient de lancer : s'il tourne encore, laissez-le finir. 2. Le poste liste les ports série qu'il voit et essaie de lire des trames sur chacun. Il vous propose celui qui répond. 3. Posez un objet sur la balance : le poids doit s'afficher et bouger. @@ -200,7 +209,7 @@ réglage qui fait apparaître la question. > **Si aucun port ne répond** : la balance est-elle allumée et branchée ? Voir > TROUBLESHOOTING.md, « Le poids ne s'affiche pas ». -## Étape 6 — L'imprimante et le décalage d'étiquette (4 min) +## Étape 5 — L'imprimante et le décalage d'étiquette (4 min) C'est l'étape la plus longue, et celle qui décide de la qualité du résultat. @@ -219,7 +228,7 @@ C'est l'étape la plus longue, et celle qui décide de la qualité du résultat. > « pour l'utilisateur » et non pour la machine. Le service ne la voit alors pas. Voir > TROUBLESHOOTING.md, « L'imprimante n'apparaît pas dans la liste ». -## Étape 7 — Le catalogue (2 min) +## Étape 6 — Le catalogue (2 min) 1. Page **Catalogue**. Choisissez la **source** (partage WebDAV, ou dépôt local) et le **numéro de ce poste**. @@ -254,11 +263,11 @@ premier. |---|---| | Page **Poste** → **Exporter la configuration** | | | Décochez **« inclure le matériel »** | | -| Le fichier part sur une clé USB | Étapes 1 à 4 ci-dessus (installation, redémarrage, mot de passe) | +| Le fichier part sur une clé USB | Étapes 1 à 3 ci-dessus (installation, redémarrage, mot de passe) | | | Page **Poste** → **Importer**, glissez le fichier | | | Le poste montre le **diff champ par champ**. Lisez-le, confirmez. | -| | Étapes 5 et 6 : balance et imprimante (le décalage est déjà bon) | -| | Étape 7 : **numéro de ce poste** | +| | Étapes 4 et 5 : balance et imprimante (le décalage est déjà bon) | +| | Étape 6 : **numéro de ce poste** | > **Le décalage voyage vraiment** : il est dans la configuration livrée, avec le > noircissement, la vitesse et les réglages série de la balance. Vérifiez-le sur la @@ -268,11 +277,11 @@ premier. une **empreinte de 8 caractères**. Les quatre postes doivent afficher **exactement la même chaîne**. -> **Comparez-la seulement quand les sept étapes sont finies.** Tant que le numéro de +> **Comparez-la seulement quand les six étapes sont finies.** Tant que le numéro de > poste, la balance et l'imprimante ne sont pas réglés, la configuration est incomplète : > le poste tourne en **configuration d'usine** et affiche l'empreinte de cette > configuration-là, pas celle du fichier. Ce n'est pas une panne, et c'est aussi pour ça -> que l'étape 3 — le redémarrage — se fait avant : à ce moment-là, l'écran client affiche +> que l'étape 2 — le redémarrage — se fait avant : à ce moment-là, l'écran client affiche > « Poste en configuration d'usine » et c'est normal. ``` @@ -373,6 +382,10 @@ différents n'affichent pas la même. ## Désinstaller un poste +Les scripts du poste sont sous `C:\ProgramData\OpenScale\installer\`, dans un dossier par +version installée — l'installation les y a rangés pour ce jour-là. PowerShell **en +administrateur**, dans ce dossier : + ```powershell .\uninstall.ps1 ``` diff --git a/README.md b/README.md index 83339ec..c96d3c1 100644 --- a/README.md +++ b/README.md @@ -124,15 +124,43 @@ Code et commentaires en **anglais**, documentation et messages utilisateur en détecteur de course sont détaillés dans [`docs/06-developpement.md`](docs/06-developpement.md). +## Installer un poste + +Sur un Windows nu, sans dépôt, sans Go et sans archive à décompresser — les droits +administrateur sont demandés en cours de route : + +```powershell +irm https://raw.githubusercontent.com/lostmind84/OpenScale/main/deploy/windows/bootstrap.ps1 | iex +``` + +
+Depuis une invite de commandes (cmd) + +```cmd +curl -fsSL https://raw.githubusercontent.com/lostmind84/OpenScale/main/deploy/windows/bootstrap.cmd -o %TEMP%\openscale.cmd && %TEMP%\openscale.cmd +``` + +
+ +La commande prend la dernière version publiée, **vérifie son empreinte avant de la +décompresser**, pose trois questions — mot de passe de la session du poste, production ou +pilote, ouverture de session automatique — puis installe tout : compte Windows dédié, +service, tâche du kiosque, réglages d'alimentation, fiche d'installation à ranger dans le +classeur du magasin. + +Le parcours complet du bénévole — redémarrage de recette, balance, imprimante, catalogue, +et **l'installation par clé USB d'un poste sans Internet** — est dans +[`INSTALLATION.md`](INSTALLATION.md). + ## Déployer Un poste ne s'installe pas en copiant `openscale.exe` : il lui faut les scripts, la tâche planifiée ou les unités systemd, la configuration et les documents du bénévole. `make release` assemble le tout en une archive par plateforme, et pousser un tag de -version fait la même chose sur la page *Releases*. +version fait la même chose sur la page *Releases*. C'est cette archive que la commande +ci-dessus télécharge. -L'installation elle-même est décrite dans [`INSTALLATION.md`](INSTALLATION.md), écrit pour -un bénévole ; la fabrication des archives dans +La fabrication des archives est décrite dans [`docs/06-developpement.md`](docs/06-developpement.md). ## Licence diff --git a/SUIVI.md b/SUIVI.md index 4e1eacf..98686d0 100644 --- a/SUIVI.md +++ b/SUIVI.md @@ -1172,10 +1172,10 @@ d'étiquette, clone la configuration vers les 3 autres postes et vérifie l'empr | Membre du critère | Ce qui le porte | |---|---| -| installer seul, sans développeur | `deploy/windows/install.ps1` — compte local, ACL, service, tâche, alimentation, Windows Update, fiche d'installation. Idempotent, chaque appel natif gardé | +| installer seul, sans développeur | **une commande** — `deploy/windows/bootstrap.ps1`, téléchargé depuis `main`, résout la dernière release, vérifie son empreinte AVANT de décompresser, débloque, pose trois questions et appelle `deploy/windows/install.ps1` — compte local, ACL, service, tâche, alimentation, Windows Update, fiche d'installation. Idempotent, chaque appel natif gardé | | **revenir seul sur l'écran client** | ouverture de session automatique écrite par l'installeur (bloquant-7) + tâche `OpenScale-Kiosk` en `InteractiveToken` + `openscale kiosk` (`internal/kiosk`) | -| en 15 minutes | `INSTALLATION.md` **compte les étapes : 17 minutes** pour le premier poste, ~7 pour les suivants. L'écart est dit, pas caché | -| régler le décalage avec l'aperçu | écran Étiquette (front admin) — étape 6 de la notice, celle qui fait dépasser le compte | +| en 15 minutes | `INSTALLATION.md` **compte les étapes : 15 minutes** pour le premier poste, ~7 pour les suivants. Les deux minutes d'écart étaient le téléchargement et la décompression, que la commande unique absorbe | +| régler le décalage avec l'aperçu | écran Étiquette (front admin) — étape 5 de la notice, la plus longue des six | | cloner et **vérifier l'empreinte** | `openscale config export` / `fingerprint`, et le test qui prouve les deux sens : même empreinte pour deux postes réglés à l'identique, empreinte différente dès qu'un réglage métier diverge | | mettre à jour sans risque | `update.ps1` / `update.sh` : arrêt borné, sauvegarde horodatée, vérification de `/healthz`, **restauration automatique** | | désinstaller sans casser le retour en arrière | `uninstall.ps1` restaure `restore.json` et **garde les données** (important-15) | @@ -1342,6 +1342,7 @@ engageantes : | Date | Événement | |---|---| +| 31/07/2026 | **Un poste s'installe en une commande** (`deploy/windows/bootstrap.ps1`, `bootstrap.cmd`). Sept gestes disparaissent, dont **trois qui échouaient en silence chez un bénévole** : la case « Débloquer » oubliée, que la stratégie d'exécution transforme en refus parlant de « fichier téléchargé depuis Internet » et jamais d'OpenScale ; PowerShell ouvert sans élévation, dont le refus arrivait après le téléchargement ; et le mauvais répertoire, qui faisait accuser l'archive. Le script vit sur `main` et **ne nomme aucune version** — il demande `/releases/latest`, seul point de l'API qui exclut brouillons et pré-versions par contrat. **L'empreinte est vérifiée avant l'extraction**, jamais après : décompresser une archive non vérifiée écrit sur le disque des fichiers dont la ligne suivante exécute un en administrateur. Trois questions au lieu de zéro paramètre — mot de passe de la session (**quatre caractères minimum**, l'arbitrage du jour : ce mot de passe est déjà en clair dans `Winlogon\DefaultPassword` et sur la fiche, donc sa longueur ne protège pas ce poste ; le refus réseau `SeDenyNetworkLogonRight` a été **proposé et écarté**, le réseau du magasin étant de confiance), production ou pilote, ouverture de session automatique. **Le secret ne sort jamais du processus** : `install.ps1` est appelé avec un `[securestring]`, et l'auto-élévation est REFUSÉE quand `-SessionPassword` est fourni — une relance élevée ferait passer le mot de passe par une ligne de commande, lisible dans la liste des processus. **Deux trous rebouchés en chemin** : `install.ps1` ne copie aucun script, donc un poste installé depuis `%TEMP%` n'aurait eu ni désinstalleur ni script de mise à jour — le dossier extrait est conservé sous `ProgramData\OpenScale\installer\` ; et `-Version`, qui installe une release antérieure, aurait fait échouer l'appel sur un paramètre inconnu **après** les trois questions, donc les paramètres sont confrontés à ce qu'`install.ps1` déclare. `INSTALLATION.md` retombe à **15 minutes comptées**, la voie clé USB reste écrite pour le poste hors ligne. **12 tests de `deploy/`** ajoutés aux 32 existants | | 31/07/2026 | **Un catalogue redéposé à l'identique vidait le plan de travail de la page Catalogue.** Signalé depuis le poste : *« les articles à corriger dans Odoo et les autres sections sont vides alors même que l'import a bien détecté des problèmes »*. Les compteurs de l'encadré du haut viennent de la **ligne d'import** ; les trois listes en dessous viennent des **signalements**, lus par identifiant d'import — et un export redéposé à l'identique est enregistré `unchanged`, ce qu'ADR-015 tient pour un événement **nominal**, sans réécrire aucun signalement, puisqu'ils appartiennent à l'import qui a produit la grille, une ligne plus haut (`importer.unchanged`). Les écrans lisaient la **dernière ligne** : à la deuxième livraison du même fichier, « 16 anomalies · 8 non pesables » restait affiché au-dessus de trois listes répondant toutes *« Aucune anomalie sur le dernier import. »*, **et définitivement**. Le poste nomme désormais la ligne qui décrit le catalogue **en service** (`catalog_findings_id`), parce qu'il est le seul côté à voir au-delà des vingt imports servis à la page ; `failed` retombe dessus pour la même raison, `applied` et `rejected` gardent la leur — les remarques d'un lot refusé sont exactement ce qu'il faut corriger pour que le fichier suivant entre (§10.5). Même lecture qu'ADR-053, au même endroit : deux écrans, une ligne d'une table. Le tableau de bord y gagne au passage la reprise de sa ventilation « — préemballés (7), code interne 0490 (1) », perdue en même temps. **2 885 tests Go** (`go test ./... -v`, sous-tests compris : 2 878 verts, 7 écartés) sur 35 paquets et **769 tests front**, tous verts, passe `-race` verte sur les deux paquets touchés | | 31/07/2026 | **La date en barre basse mentait à chaque redémarrage** (ADR-053). L'écran client affiche en permanence *« Catalogue du … »* pour répondre à « ces prix datent de quand ? », et §14.3 en faisait **l'instant de la bascule** — un instant que **rien ne persistait** : `newHub` le posait à `Clock.Now()` sur le catalogue relu en base, donc chaque reboot, chaque mise à jour et chaque reprise après plantage redatait un catalogue que personne n'avait réimporté. Un poste privé de fichier depuis trois semaines affichait la date du matin, **soit exactement le silence que cette date existait pour révéler**. L'instant devient celui de **l'import appliqué** — `imports.occurred_at` où `result = 'applied'`, écrit dans la même transaction que le catalogue, donc impossible à contredire — relu au démarrage (`Options.CatalogAt`) et transporté par le lot jusqu'à la bascule (`BatchResult.AppliedAt` → `CatalogBatch.ImportedAt`), au lieu d'être relu à l'arrivée : le même catalogue porte le même nombre en service et après le prochain redémarrage. **Deux découvertes en chemin** : `CatalogBatch.ReceivedAt` était **écrit et jamais lu** — le champ prévu pour porter cet instant n'avait jamais été branché — et `storeCatalog` stockait `time.Time{}.UnixNano()`, un très grand nombre **négatif**, ce qui aurait fait dire « 1754 » à l'écran d'un poste sans import plutôt que « Catalogue en attente ». Persister la bascule était l'autre voie : écarté, elle demandait une écriture SQLite **dans la goroutine du Hub**, que §13.2 interdit. **2 883 tests Go** (`go test ./... -v`, sous-tests compris) sur 35 paquets et **768 tests front**, tous verts, passe `-race` verte sur les quatre paquets touchés | | 31/07/2026 | **L'origine des produits devient enfichable, et le CSV n'en est plus qu'un mode** (ADR-052). Acquisition (`ports.CatalogSource`) et format (`catalog.RowReader`) sont deux axes séparés ; ce qu'un catalogue **décide** vit dans `catalog.Assemble` et ne se réimplémente dans aucune source. Cinq constats, tous reproduits : les deux sources livrées appelaient le parseur CSV en dur, `ports.Batch` avait la forme d'un fichier, la racine tenait une `map` au lieu de son registre, `Config.Validate` codait `local_drop` et `webdav` en dur dans le domaine, et **la coupe 2 ne couvrait pas les sources** — `internal/web` aurait pu importer un paquet source sans un mot. Contrôle 47 supprimé, numéro laissé en trou. `internal/catalog/example` livré : source d'ERP par HTTP, paginée, **enregistrée nulle part**. **2 835 tests Go** verts sur 35 paquets, passe `-race` verte. Trois découvertes en écrivant l'exemple, dont `next_page` cherché avant `products` — qui lit la page 1 et l'appelle le catalogue entier, **en silence** | diff --git a/deploy/bootstrap_test.go b/deploy/bootstrap_test.go new file mode 100644 index 0000000..51fa70b --- /dev/null +++ b/deploy/bootstrap_test.go @@ -0,0 +1,271 @@ +package deploy + +import ( + "path/filepath" + "regexp" + "strings" + "testing" + + "openscale/internal/domain" + "openscale/internal/update" +) + +// Les tests de l'installation en une commande. +// +// Ils lisent bootstrap.ps1 et bootstrap.cmd comme deploy_test.go lit les autres scripts : +// par le texte, sans Windows sous la main. Ce que Windows seul peut prouver — l'invite +// UAC, la réponse de l'API, le service qui démarre — reste la recette de §15.2 ; ce qui +// est vérifiable ici l'est ici. + +// bootstrapPath is the script the one-liner downloads and runs. +const bootstrapPath = "bootstrap.ps1" + +// TestTheBootstrapNeverNamesAVersion is what makes the one-liner survive a release. +// +// The URL of the command points at `main`, not at a release asset: the file is downloaded +// as it is today, forever. A version number written anywhere in it would install v0.9 on a +// station set up in two years, and nobody would notice — the installation would succeed. +func TestTheBootstrapNeverNamesAVersion(t *testing.T) { + script := codeOnly(readFile(t, filepath.Join("windows", bootstrapPath))) + // A literal tag, in quotes, is what is forbidden. The -Version parameter is allowed to + // exist: it names no version, it receives one. + hardCoded := regexp.MustCompile(`['"]v?\d+\.\d+(\.\d+)?['"]`) + for _, line := range strings.Split(script, "\n") { + if found := hardCoded.FindString(line); found != "" { + t.Errorf("%s fige une version (%s) : le one-liner pointe main et doit "+ + "demander la dernière release à chaque exécution\n %s", + bootstrapPath, found, strings.TrimSpace(line)) + } + } + if !strings.Contains(script, "releases/latest") { + t.Errorf("%s ne demande pas /releases/latest : c'est le seul point de l'API qui "+ + "exclut les brouillons et les pré-versions par contrat", bootstrapPath) + } +} + +// TestTheBootstrapChecksTheFingerprintBeforeItExtracts is an ORDER, and nothing else in +// the file recalls it to whoever edits it. +// +// Expand-Archive on an unverified archive writes files to the disk before anyone knows +// where they came from — and the next line runs one of them as administrator. +func TestTheBootstrapChecksTheFingerprintBeforeItExtracts(t *testing.T) { + script := codeOnly(readFile(t, filepath.Join("windows", bootstrapPath))) + hash := strings.Index(script, "Get-FileHash") + extract := strings.Index(script, "Expand-Archive") + if hash < 0 { + t.Fatalf("%s ne calcule aucune empreinte : il décompresse ce que le réseau lui a "+ + "donné", bootstrapPath) + } + if extract < 0 { + t.Fatalf("%s ne décompresse rien", bootstrapPath) + } + if hash > extract { + t.Errorf("%s décompresse (position %d) AVANT de vérifier l'empreinte (position %d)", + bootstrapPath, extract, hash) + } + if !strings.Contains(script, "SHA256SUMS-archives.txt") { + t.Errorf("%s ne télécharge pas SHA256SUMS-archives.txt : il n'a rien à quoi "+ + "comparer", bootstrapPath) + } +} + +// TestTheBootstrapUnblocksWhatItExtracted removes step 1 of INSTALLATION.md. +// +// Every file extracted from a downloaded archive carries the Internet zone marker. Without +// Unblock-File, install.ps1 is refused by the execution policy with a message that speaks +// of « fichier téléchargé depuis Internet » and never of OpenScale — the « clic droit → +// Propriétés → Débloquer » nobody does the first time. +func TestTheBootstrapUnblocksWhatItExtracted(t *testing.T) { + script := codeOnly(readFile(t, filepath.Join("windows", bootstrapPath))) + if !strings.Contains(script, "Unblock-File") { + t.Errorf("%s ne débloque pas ce qu'il vient d'extraire : la stratégie d'exécution "+ + "refusera install.ps1", bootstrapPath) + } +} + +// TestTheInstallerDeclaresEveryParameterTheBootstrapPasses closes a silent hole. +// +// A -SessionPassword the installer does not declare is dropped by PowerShell. The station +// would come out with a random password, the volunteer would type the one they chose, and +// nobody would know why the session refuses it. +func TestTheInstallerDeclaresEveryParameterTheBootstrapPasses(t *testing.T) { + script := codeOnly(readFile(t, filepath.Join("windows", bootstrapPath))) + installer := codeOnly(readFile(t, filepath.Join("windows", "install.ps1"))) + + table := regexp.MustCompile(`(?s)\$installerArguments\s*=\s*@\{(.*?)\n\}`). + FindStringSubmatch(script) + if table == nil { + t.Fatalf("%s ne construit pas ses arguments dans une table $installerArguments : "+ + "ce test ne sait plus ce qui est passé à install.ps1", bootstrapPath) + } + names := regexp.MustCompile(`(?m)^\s*(\w+)\s*=`).FindAllStringSubmatch(table[1], -1) + if len(names) == 0 { + t.Fatal("aucun argument trouvé dans $installerArguments") + } + for _, name := range names { + if !strings.Contains(installer, "$"+name[1]) { + t.Errorf("install.ps1 ne déclare pas le paramètre -%s que %s lui passe : "+ + "PowerShell le laissera tomber en silence", name[1], bootstrapPath) + } + } +} + +// TestTheSessionPasswordNeverReachesALogOrACommandLine. +// +// install.log stays on the station; the installation sheet goes to the binder. And an +// argument on a command line is readable in the process list by ANY user of the machine — +// which is why the bootstrap refuses to elevate itself when a password was given. +func TestTheSessionPasswordNeverReachesALogOrACommandLine(t *testing.T) { + for _, name := range []string{bootstrapPath, "install.ps1"} { + script := codeOnly(readFile(t, filepath.Join("windows", name))) + for _, line := range strings.Split(script, "\n") { + if !strings.Contains(line, "SessionPassword") { + continue + } + for _, forbidden := range []string{"Write-Host", "Write-Step", "Start-Process"} { + if strings.Contains(line, forbidden) { + t.Errorf("%s fait passer le mot de passe de session par %s :\n %s", + name, forbidden, strings.TrimSpace(line)) + } + } + } + } +} + +// TestTheSessionPasswordHasAFloorAndAConfirmation. +// +// Four characters is the arbitrage of 31/07/2026 — the password is already in clear in +// Winlogon\DefaultPassword, so shortening it changes nothing for whoever touches the +// station. What it does NOT excuse is a password typed wrong: a station whose session +// nobody can open is a station nobody can repair. +func TestTheSessionPasswordHasAFloorAndAConfirmation(t *testing.T) { + script := codeOnly(readFile(t, filepath.Join("windows", bootstrapPath))) + if !strings.Contains(script, "MinimumPasswordLength = 4") { + t.Errorf("%s ne déclare pas « MinimumPasswordLength = 4 » : le plancher du "+ + "31/07/2026 n'est plus lisible dans le script", bootstrapPath) + } + if strings.Count(script, "Read-Host") < 2 { + t.Errorf("%s ne demande pas le mot de passe deux fois", bootstrapPath) + } + if !strings.Contains(script, "AsSecureString") { + t.Errorf("%s lit le mot de passe en clair : -AsSecureString existe pour qu'il ne "+ + "reste ni dans la console ni dans l'historique", bootstrapPath) + } +} + +// TestTheBootstrapRefusesToElevateWithASecretOnTheCommandLine. +// +// Relaunching an elevated window passes the parameters through a command line. The +// interactive path elevates itself and asks AFTERWARDS, in the elevated window; the +// scripted path demands an already-elevated console. This is not a limitation to lift +// later: it is the choice of never writing a secret into an argv. +func TestTheBootstrapRefusesToElevateWithASecretOnTheCommandLine(t *testing.T) { + script := codeOnly(readFile(t, filepath.Join("windows", bootstrapPath))) + elevation := strings.Index(script, "Start-Process") + if elevation < 0 { + t.Fatalf("%s ne se relève jamais en administrateur", bootstrapPath) + } + // The guard is BEFORE the relaunch, or it guards nothing. + if !strings.Contains(script[:elevation], "SessionPassword") { + t.Errorf("%s se relève en administrateur sans avoir vérifié qu'aucun mot de passe "+ + "ne lui a été passé : le secret partirait sur la ligne de commande", bootstrapPath) + } +} + +// TestBothEntryPointsNameTheSameScript keeps the CMD form from drifting. +func TestBothEntryPointsNameTheSameScript(t *testing.T) { + command := readFile(t, filepath.Join("windows", "bootstrap.cmd")) + if !strings.Contains(command, bootstrapPath) { + t.Errorf("bootstrap.cmd n'appelle pas %s", bootstrapPath) + } + for what, needle := range map[string]string{ + "le contournement de la stratégie d'exécution": "-ExecutionPolicy Bypass", + "le dépôt": domain.DefaultUpdateRepository, + } { + if !strings.Contains(command, needle) { + t.Errorf("bootstrap.cmd ne porte pas %s (« %s » absent)", what, needle) + } + } + // cmd.exe reads CP850: an accented character in an echo comes out as mojibake on the + // screen of the volunteer. start.bat has known this since the first day. + for _, line := range strings.Split(command, "\n") { + for _, letter := range line { + if letter > 127 { + t.Errorf("bootstrap.cmd porte un caractère accentué, que cmd.exe affichera "+ + "de travers :\n %s", strings.TrimSpace(line)) + break + } + } + } +} + +// TestTheBootstrapAsksTheRepositoryTheBinaryWasBuiltFor. +// +// « lostmind84/OpenScale » and « api.github.com » are already spelled in the Go code, and a +// third place that spells them is a third place to forget the day the repository moves. +func TestTheBootstrapAsksTheRepositoryTheBinaryWasBuiltFor(t *testing.T) { + script := codeOnly(readFile(t, filepath.Join("windows", bootstrapPath))) + if !strings.Contains(script, domain.DefaultUpdateRepository) { + t.Errorf("%s n'interroge pas %s, le dépôt que internal/domain/config.go compile", + bootstrapPath, domain.DefaultUpdateRepository) + } + host := strings.TrimPrefix(update.DefaultBaseURL, "https://") + if !strings.Contains(script, host) { + t.Errorf("%s n'interroge pas %s, l'hôte que internal/update/github.go compile", + bootstrapPath, host) + } +} + +// TestTheInstallerScriptsSurviveTheInstallation is a hole the bootstrap would have dug. +// +// install.ps1 copies the binary, the delivered configuration and the two notices into +// Program Files. It copies NO script. Today uninstall.ps1, update.ps1 and harden.ps1 +// survive because the archive stays on the Desktop; a bootstrap that cleaned %TEMP% would +// leave a station with no uninstaller — and TROUBLESHOOTING.md would send a volunteer +// looking for a file that no longer exists. +func TestTheInstallerScriptsSurviveTheInstallation(t *testing.T) { + script := codeOnly(readFile(t, filepath.Join("windows", bootstrapPath))) + keep := regexp.MustCompile(`(Move-Item|Copy-Item)[^\n]*[Ii]nstaller`) + if !keep.MatchString(script) { + t.Errorf("%s ne déplace ni ne copie le dossier extrait vers un emplacement "+ + "durable : le poste n'aura plus de désinstalleur", bootstrapPath) + } +} + +// TestTheOneLinerIsTheSameEverywhereItIsWritten. +// +// The command is copied by hand into three documents. A README that names a file the +// repository does not carry is a first impression that fails on the first line. +func TestTheOneLinerIsTheSameEverywhereItIsWritten(t *testing.T) { + expected := "https://raw.githubusercontent.com/" + domain.DefaultUpdateRepository + + "/main/deploy/windows/" + bootstrapPath + for _, document := range []string{ + filepath.Join("..", "README.md"), + filepath.Join("..", "INSTALLATION.md"), + filepath.Join("..", "handbook", "getting-started.md"), + } { + if !strings.Contains(readFile(t, document), expected) { + t.Errorf("%s ne porte pas la commande d'installation (« %s » absent)", + document, expected) + } + } +} + +// TestTheBootstrapSurvivesAnOlderInstaller is the normal case, not the edge case. +// +// This file lives on main; the archives are frozen at their tag, and -Version makes +// installing an older one a documented gesture. A parameter install.ps1 does not declare +// would fail the call with « Impossible de trouver un paramètre correspondant au nom … » +// AFTER the download, the extraction and the three questions — the worst possible moment. +func TestTheBootstrapSurvivesAnOlderInstaller(t *testing.T) { + script := codeOnly(readFile(t, filepath.Join("windows", bootstrapPath))) + call := strings.Index(script, "& $installer @installerArguments") + if call < 0 { + t.Fatalf("%s n'appelle plus install.ps1 par une table d'arguments", bootstrapPath) + } + if !strings.Contains(script[:call], "Get-Content -LiteralPath $installer") { + t.Errorf("%s appelle install.ps1 sans avoir lu quels paramètres il déclare : une "+ + "version antérieure ferait échouer l'appel après les trois questions", + bootstrapPath) + } +} diff --git a/deploy/windows/bootstrap.cmd b/deploy/windows/bootstrap.cmd new file mode 100644 index 0000000..921106f --- /dev/null +++ b/deploy/windows/bootstrap.cmd @@ -0,0 +1,23 @@ +@echo off +rem =========================================================================== +rem bootstrap.cmd - installe un poste de pesee OpenScale en une commande. +rem +rem A taper dans une invite de commandes, elevee ou non : +rem +rem curl -fsSL https://raw.githubusercontent.com/lostmind84/OpenScale/main/deploy/windows/bootstrap.cmd -o %TEMP%\openscale.cmd ^&^& %TEMP%\openscale.cmd +rem +rem Ce fichier ne fait rien de plus que rappeler bootstrap.ps1, qui porte toute +rem l'installation : deux entrees, une seule logique. Le -ExecutionPolicy +rem Bypass est ce qui evite au benevole la strategie d'execution par defaut de +rem Windows, qui refuse les scripts venus d'Internet. +rem +rem Sans accent, et ce n'est pas un oubli : cmd.exe lit ce fichier en CP850, et +rem toute lettre accentuee sort de travers a l'ecran. start.bat le sait depuis +rem le premier jour, et un test de deploy/ le verifie lettre par lettre. +rem =========================================================================== +setlocal + +set "OPENSCALE_BOOTSTRAP=https://raw.githubusercontent.com/lostmind84/OpenScale/main/deploy/windows/bootstrap.ps1" + +powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12; iex ((New-Object Net.WebClient).DownloadString('%OPENSCALE_BOOTSTRAP%'))" +exit /b %ERRORLEVEL% diff --git a/deploy/windows/bootstrap.ps1 b/deploy/windows/bootstrap.ps1 new file mode 100644 index 0000000..b35a964 --- /dev/null +++ b/deploy/windows/bootstrap.ps1 @@ -0,0 +1,440 @@ +<# +.SYNOPSIS + Installe un poste de pesée OpenScale en une seule commande. + +.DESCRIPTION + C'EST LE SEUL FICHIER DU PROJET QUI VIT HORS DE L'ARCHIVE. Il est téléchargé et exécuté + d'une traite, depuis n'importe quelle console, élevée ou non : + + irm https://raw.githubusercontent.com/lostmind84/OpenScale/main/deploy/windows/bootstrap.ps1 | iex + + Ce qu'il fait, dans cet ordre : + + 1. CONTRÔLES PRÉALABLES — Windows 64 bits, PowerShell 5.1, TLS 1.2. Échouer sur la + première ligne coûte moins cher qu'échouer après le téléchargement. + 2. ÉLÉVATION. Le one-liner est tapé dans une console ordinaire neuf fois sur dix : ce + script se recopie dans %TEMP% et se relance par Start-Process -Verb RunAs. Avec + -SessionPassword, cette relance est REFUSÉE — voir plus bas. + 3. La dernière release, demandée à l'API. AUCUN NUMÉRO DE VERSION N'EST ÉCRIT ICI : + l'URL ci-dessus pointe la branche main, ce fichier est donc téléchargé tel quel + pendant des années, et une version figée s'installerait indéfiniment. + 4. Le zip ET SHA256SUMS-archives.txt, puis la comparaison des empreintes. ★ AVANT + l'extraction : décompresser une archive non vérifiée écrit sur le disque des + fichiers dont on ne sait pas d'où ils viennent, et la ligne suivante en exécute un + en administrateur. + 5. Unblock-File sur ce qui vient d'être extrait. C'est l'étape 1 d'INSTALLATION.md — + « clic droit, Propriétés, Débloquer » — que personne ne fait du premier coup, et + sans laquelle la stratégie d'exécution refuse install.ps1. + 6. Les trois questions qui appartiennent vraiment à un humain. + 7. install.ps1, appelé DANS LE MÊME PROCESSUS : le mot de passe de session ne passe ni + par une ligne de commande, ni par un fichier, ni par une variable d'environnement. + 8. Le dossier extrait est conservé sous ProgramData. install.ps1 ne copie aucun + script : sans cette étape, le poste n'aurait ni désinstalleur ni script de mise à + jour, et TROUBLESHOOTING.md enverrait un bénévole chercher un fichier disparu. + + POUR UN POSTE SANS INTERNET, rien de tout cela ne sert : l'archive se copie sur une clé + USB et install.ps1 se lance seul, comme avant. Voir INSTALLATION.md. + +.PARAMETER SessionPassword + Le mot de passe du compte Windows « openscale », en SecureString. Absent, il est + demandé ; refusé, il est tiré au sort sur 20 caractères. + + ★ LE FOURNIR INTERDIT L'AUTO-ÉLÉVATION, et ce n'est pas une limite qu'on lèvera : la + relance élevée fait passer ses paramètres par une ligne de commande, que n'importe quel + utilisateur de la machine lit dans la liste des processus. En mode scripté, la console + doit donc être déjà élevée. + +.PARAMETER Pilot + Service en démarrage manuel : l'application Access reste relançable en deux minutes. + +.PARAMETER SkipAutoLogon + N'écrit pas l'ouverture de session automatique. Un poste en libre-service en a besoin : + c'est elle qui le fait revenir sur l'écran client après une coupure de courant. + +.PARAMETER Yes + Ne pose aucune question et prend les valeurs par défaut. + +.PARAMETER Version + Le tag à installer, au lieu de la dernière release. Sert à aligner un poste sur les + autres, ou à revenir en arrière. + +.PARAMETER Relaunched + Interne. Marque la fenêtre ouverte par l'auto-élévation, pour qu'elle attende une touche + avant de se fermer sur ce qu'elle vient d'afficher. + +.EXAMPLE + irm https://raw.githubusercontent.com/lostmind84/OpenScale/main/deploy/windows/bootstrap.ps1 | iex + +.EXAMPLE + # Avec des paramètres, depuis une console DÉJÀ élevée : + & ([scriptblock]::Create((irm https://raw.githubusercontent.com/lostmind84/OpenScale/main/deploy/windows/bootstrap.ps1))) -Pilot -Yes +#> +[CmdletBinding()] +param( + [securestring]$SessionPassword, + [switch]$Pilot, + [switch]$SkipAutoLogon, + [switch]$Yes, + [string]$Version, + [string]$InstallDir, + [string]$DataRoot, + [switch]$Relaunched) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +# Le dépôt et l'hôte de l'API sont ceux que le binaire compile — DefaultUpdateRepository +# dans internal/domain/config.go, DefaultBaseURL dans internal/update/github.go. Un test +# de deploy/ compare les trois, pour qu'il n'existe pas un troisième endroit à corriger le +# jour où le dépôt déménage. +$script:Repository = 'lostmind84/OpenScale' +$script:ApiHost = 'api.github.com' +$script:RawUrl = "https://raw.githubusercontent.com/$script:Repository/main/deploy/windows/bootstrap.ps1" +$script:ArchiveSuffix = '-windows-amd64.zip' +$script:ChecksumAsset = 'SHA256SUMS-archives.txt' +$script:UserAgent = 'OpenScale-bootstrap' + +# Le plancher du mot de passe de session, et l'arbitrage du 31/07/2026 derrière lui : le +# mot de passe du compte est DÉJÀ en clair dans Winlogon\DefaultPassword — install.ps1 l'y +# écrit lui-même — donc sur un poste en libre-service, où l'accès physique vaut l'accès +# administrateur, le raccourcir ne change rien pour qui touche la machine. Ce que ça ne +# dispense pas de faire, c'est la confirmation : personne ne peut ouvrir la session d'un +# poste dont le mot de passe a été tapé de travers. +$script:MinimumPasswordLength = 4 + +function Test-Elevated { + <# + .SYNOPSIS + Vrai si la console tourne en administrateur. + .DESCRIPTION + C'est la seule chose que ce script duplique de common.ps1, et il n'a pas le choix : + common.ps1 est dans l'archive, et l'élévation se décide avant le téléchargement. Le + nom diffère de Test-Administrator exprès — les deux fonctions coexistent dans ce + processus dès que common.ps1 est chargé, plus bas. + #> + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + (New-Object Security.Principal.WindowsPrincipal $identity).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator) +} + +function Write-Progression { + <# + .SYNOPSIS + Une étape, à l'écran. + .DESCRIPTION + Ce script écrit à l'écran et NULLE PART AILLEURS : le journal du poste + (install.log) commence avec install.ps1, et rien de ce qui se passe ici ne mérite de + survivre à la fenêtre. + #> + param([Parameter(Mandatory)][string]$Message) + Write-Host " $Message" +} + +function Test-SameSecret { + <# + .SYNOPSIS + Vrai si les deux saisies sont le même mot de passe. + .DESCRIPTION + Le passage par Marshal est ce qui rend cette comparaison possible sous WINDOWS + POWERSHELL 5.1 : « ConvertFrom-SecureString -AsPlainText » n'existe qu'à partir de + PowerShell 7, et un script d'installation qui ne tourne pas sur le PowerShell livré + avec Windows ne sert à rien. + + Les deux chaînes en clair vivent le temps de la comparaison et la mémoire non gérée + est remise à zéro dans un finally — y compris si la comparaison lève. + #> + param( + [Parameter(Mandatory)][securestring]$First, + [Parameter(Mandatory)][securestring]$Second) + + $firstPointer = [IntPtr]::Zero + $secondPointer = [IntPtr]::Zero + try { + $firstPointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($First) + $secondPointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($Second) + [Runtime.InteropServices.Marshal]::PtrToStringBSTR($firstPointer) -ceq + [Runtime.InteropServices.Marshal]::PtrToStringBSTR($secondPointer) + } + finally { + if ($firstPointer -ne [IntPtr]::Zero) { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($firstPointer) } + if ($secondPointer -ne [IntPtr]::Zero) { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($secondPointer) } + } +} + +function Get-Answer { + <# + .SYNOPSIS + Une question fermée, avec sa réponse par défaut. + #> + param( + [Parameter(Mandatory)][string]$Question, + [Parameter(Mandatory)][bool]$Default) + + $suffix = if ($Default) { '[O/n]' } else { '[o/N]' } + $typed = (Read-Host "$Question $suffix").Trim() + if ([string]::IsNullOrEmpty($typed)) { return $Default } + $typed -match '^[oOyY]' +} + +# --- 1. Contrôles préalables -------------------------------------------------------- +if ($PSVersionTable.PSVersion.Major -lt 5) { + throw "OpenScale demande Windows PowerShell 5.1 ou plus récent (trouvé : $($PSVersionTable.PSVersion))." +} +if (-not [Environment]::Is64BitOperatingSystem) { + throw 'OpenScale est publié pour Windows 64 bits, et ce système est en 32 bits.' +} +# TLS 1.2 n'est pas le défaut de .NET sous Windows PowerShell 5.1, et raw.githubusercontent +# comme api.github.com refusent tout ce qui est en dessous : sans cette ligne, le premier +# téléchargement échoue sur « La connexion sous-jacente a été fermée », qui ne dit rien. +[Net.ServicePointManager]::SecurityProtocol = +[Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + +Write-Host '' +Write-Host '=========================================================================' +Write-Host ' Installation d''un poste de pesée OpenScale' +Write-Host '=========================================================================' + +# --- 2. Élévation ------------------------------------------------------------------- +if (-not (Test-Elevated)) { + # ★ LE REFUS, ET SA RAISON. Relancer une fenêtre élevée fait passer les paramètres par + # une ligne de commande, visible dans la liste des processus par n'importe quel + # utilisateur de la machine. Un mot de passe n'y va pas. + if ($SessionPassword) { + throw 'avec -SessionPassword, la console doit DÉJÀ être ouverte en administrateur : ' + + 'l''auto-élévation ferait passer le mot de passe par une ligne de commande, où il ' + + 'est lisible par tout le monde. Menu Démarrer, tapez powershell, clic droit, ' + + 'Exécuter en tant qu''administrateur.' + } + + $relaunched = Join-Path $env:TEMP 'openscale-bootstrap.ps1' + if ($PSCommandPath) { Copy-Item -LiteralPath $PSCommandPath -Destination $relaunched -Force } + else { + # Lancé par « irm | iex » : ce script n'existe nulle part sur le disque, et la seule + # façon d'en obtenir une copie fidèle est de le redemander à l'adresse d'où il vient. + Invoke-WebRequest -Uri $script:RawUrl -OutFile $relaunched -UseBasicParsing + } + + $arguments = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $relaunched, '-Relaunched') + if ($Pilot) { $arguments += '-Pilot' } + if ($SkipAutoLogon) { $arguments += '-SkipAutoLogon' } + if ($Yes) { $arguments += '-Yes' } + if ($Version) { $arguments += @('-Version', $Version) } + if ($InstallDir) { $arguments += @('-InstallDir', $InstallDir) } + if ($DataRoot) { $arguments += @('-DataRoot', $DataRoot) } + + Write-Host '' + Write-Progression 'droits administrateur requis : une fenêtre va s''ouvrir, répondez Oui.' + try { + Start-Process -FilePath 'powershell.exe' -Verb RunAs -ArgumentList $arguments + } + catch { + # Refuser l'invite de Windows lève « L'opération a été annulée par l'utilisateur », + # message d'API qui laisserait croire à une panne. Ce n'en est pas une : rien n'a été + # téléchargé, rien n'a été écrit, et recliquer suffit. + throw 'l''autorisation d''administrateur a été refusée, et rien n''a été installé. ' + + 'Un poste de pesée crée un compte Windows, un service et une tâche planifiée : ' + + 'aucun de ces trois gestes n''existe sans elle. Relancez la commande et répondez Oui.' + } + Write-Progression 'l''installation continue dans la nouvelle fenêtre.' + return +} + +# --- 3. La release ------------------------------------------------------------------ +# /releases/latest et non /releases : ce point de l'API exclut les brouillons et les +# pré-versions PAR CONTRAT, ce qui évite d'avoir à les filtrer ici. +$releaseUrl = if ($Version) { + "https://$script:ApiHost/repos/$script:Repository/releases/tags/$Version" +} +else { + "https://$script:ApiHost/repos/$script:Repository/releases/latest" +} + +Write-Host '' +Write-Progression 'recherche de la version à installer...' +try { + $release = Invoke-RestMethod -Uri $releaseUrl -Headers @{ 'User-Agent' = $script:UserAgent } -UseBasicParsing +} +catch { + throw "impossible de joindre $script:ApiHost ($($_.Exception.Message)). Ce poste a-t-il " + + 'accès à Internet ? Sinon, installez depuis une clé USB : voir INSTALLATION.md.' +} + +$archiveAsset = $release.assets | Where-Object { $_.name.EndsWith($script:ArchiveSuffix) } | Select-Object -First 1 +$checksumAsset = $release.assets | Where-Object { $_.name -eq $script:ChecksumAsset } | Select-Object -First 1 +if (-not $archiveAsset) { + throw "la release $($release.tag_name) ne publie aucune archive *$script:ArchiveSuffix." +} +if (-not $checksumAsset) { + throw "la release $($release.tag_name) ne publie pas $script:ChecksumAsset : il n'y a " + + 'rien à quoi comparer ce qui va être téléchargé, et rien ne sera installé.' +} +Write-Progression "version $($release.tag_name) — $($archiveAsset.name)" + +# --- 4. Téléchargement, puis vérification AVANT toute extraction -------------------- +$workspace = Join-Path $env:TEMP "openscale-$($release.tag_name)" +if (Test-Path $workspace) { Remove-Item -LiteralPath $workspace -Recurse -Force } +New-Item -ItemType Directory -Path $workspace -Force | Out-Null + +$archive = Join-Path $workspace $archiveAsset.name +$checksums = Join-Path $workspace $script:ChecksumAsset +Write-Progression "téléchargement ($([math]::Round($archiveAsset.size / 1MB, 1)) Mo)..." +# La barre de progression d'Invoke-WebRequest divise son débit par dix sur un gros +# fichier : elle repeint la console à chaque bloc reçu. +$previousProgress = $ProgressPreference +$ProgressPreference = 'SilentlyContinue' +try { + Invoke-WebRequest -Uri $archiveAsset.browser_download_url -OutFile $archive -UseBasicParsing + Invoke-WebRequest -Uri $checksumAsset.browser_download_url -OutFile $checksums -UseBasicParsing +} +finally { $ProgressPreference = $previousProgress } + +# Le fichier est celui que produit « sha256sum *.zip » : « », deux +# espaces. On y cherche le nom, pas la position. +$expected = '' +foreach ($line in Get-Content -LiteralPath $checksums) { + $parts = $line -split '\s+', 2 + if ($parts.Count -eq 2 -and $parts[1].Trim().TrimStart('*') -eq $archiveAsset.name) { + $expected = $parts[0].Trim() + } +} +if (-not $expected) { + throw "$script:ChecksumAsset ne porte aucune empreinte pour $($archiveAsset.name)." +} +$actual = (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash +if ($actual -ne $expected.ToUpperInvariant()) { + Remove-Item -LiteralPath $archive -Force + throw "l'archive téléchargée ne correspond pas à son empreinte publiée. Attendu " + + "$expected, obtenu $actual. Rien n'a été installé, et le fichier a été supprimé." +} +Write-Progression 'empreinte vérifiée' + +# --- 5. Extraction, puis déblocage -------------------------------------------------- +Expand-Archive -LiteralPath $archive -DestinationPath $workspace -Force +$extracted = Get-ChildItem -LiteralPath $workspace -Directory | Select-Object -First 1 +if (-not $extracted) { throw "l'archive $($archiveAsset.name) ne contient aucun dossier." } + +# Tout fichier extrait d'une archive téléchargée porte la marque de zone Internet, et la +# stratégie d'exécution refuse alors install.ps1 avec un message qui parle de « fichier +# téléchargé depuis Internet » et jamais d'OpenScale. +Get-ChildItem -LiteralPath $extracted.FullName -Recurse -File | Unblock-File +Write-Progression "décompressé dans $($extracted.FullName)" + +$installer = Join-Path $extracted.FullName 'install.ps1' +if (-not (Test-Path $installer)) { + throw "install.ps1 est absent de l'archive $($archiveAsset.name)." +} +# common.ps1 est chargé pour Get-OpenScalePaths, et pour elle seule : un script qui +# reconstruirait « C:\ProgramData\OpenScale » à la main serait le second endroit à +# corriger le jour où ce chemin bouge. +. (Join-Path $extracted.FullName 'common.ps1') +$paths = if ($InstallDir -and $DataRoot) { Get-OpenScalePaths -InstallDir $InstallDir -DataRoot $DataRoot } +elseif ($InstallDir) { Get-OpenScalePaths -InstallDir $InstallDir } +elseif ($DataRoot) { Get-OpenScalePaths -DataRoot $DataRoot } +else { Get-OpenScalePaths } + +# --- 6. Les trois questions --------------------------------------------------------- +if (-not $Yes -and -not [Environment]::UserInteractive) { + throw 'aucune console interactive : ce script ne peut pas poser ses questions. ' + + 'Relancez-le avec -Yes, ou donnez ses réponses en paramètres.' +} + +if (-not $Yes) { + Write-Host '' + Write-Host ' Trois questions, puis l''installation se déroule seule.' + Write-Host '' +} + +if (-not $Yes -and -not $SessionPassword) { + Write-Host " Mot de passe de la session Windows « $script:AccountName »" + Write-Host " $script:MinimumPasswordLength caractères minimum. Vide = tiré au sort (20 caractères)." + Write-Host ' Il sera imprimé sur la fiche d''installation, à ranger dans le classeur.' + while ($true) { + $first = Read-Host ' Mot de passe' -AsSecureString + if ($first.Length -eq 0) { + Write-Progression 'mot de passe tiré au sort' + break + } + if ($first.Length -lt $script:MinimumPasswordLength) { + Write-Host " trop court : $script:MinimumPasswordLength caractères au minimum." + continue + } + $second = Read-Host ' Confirmation' -AsSecureString + if (-not (Test-SameSecret -First $first -Second $second)) { + Write-Host ' les deux saisies diffèrent.' + continue + } + $SessionPassword = $first + break + } + Write-Host '' +} + +if (-not $Yes -and -not $Pilot) { + Write-Host ' Type d''installation' + Write-Host ' [1] Production — le poste démarre seul à chaque allumage (défaut)' + Write-Host ' [2] Pilote — service en démarrage manuel, l''application Access reste relançable' + if ((Read-Host ' Votre choix').Trim() -eq '2') { $Pilot = [switch]::Present } + Write-Host '' +} + +if (-not $Yes -and -not $SkipAutoLogon) { + Write-Host ' Ouverture de session automatique' + Write-Host ' C''est elle qui fait revenir le poste sur l''écran client après une coupure' + Write-Host ' de courant. Répondez non seulement si ce poste n''est PAS en libre-service.' + if (-not (Get-Answer -Question ' L''activer ?' -Default $true)) { $SkipAutoLogon = [switch]::Present } + Write-Host '' +} + +# --- 7. install.ps1, dans le même processus ----------------------------------------- +# ★ LE MOT DE PASSE NE SORT PAS DE CE PROCESSUS. Pas de ligne de commande, pas de fichier +# temporaire, pas de variable d'environnement : un SecureString passé à un script appelé +# par l'opérateur d'appel. +$installerArguments = @{ + SessionPassword = $SessionPassword + Pilot = $Pilot + SkipAutoLogon = $SkipAutoLogon + InstallDir = $InstallDir + DataRoot = $DataRoot +} +foreach ($key in @($installerArguments.Keys)) { + if (-not $installerArguments[$key]) { $installerArguments.Remove($key) } +} + +# ★ CE SCRIPT VIT SUR MAIN, LES ARCHIVES SONT FIGÉES À LEUR TAG. -Version installe une +# release antérieure, dont l'install.ps1 ne connaît pas forcément les options d'aujourd'hui, +# et un paramètre qu'il ne déclare pas ferait échouer l'appel sur « Impossible de trouver un +# paramètre correspondant au nom … » APRÈS le téléchargement, l'extraction et les trois +# questions — le pire moment pour perdre un bénévole. On le retire, et on le dit. +$installerText = Get-Content -LiteralPath $installer -Raw +foreach ($key in @($installerArguments.Keys)) { + if ($installerText -notmatch "\`$$key\b") { + $installerArguments.Remove($key) + Write-Progression "la version $($release.tag_name) ne connaît pas l'option -$key : elle est ignorée." + if ($key -eq 'SessionPassword') { + Write-Progression 'le mot de passe de session sera tiré au sort et imprimé sur la fiche.' + } + } +} + +& $installer @installerArguments + +# --- 8. Les scripts survivent à l'installation -------------------------------------- +# install.ps1 copie le binaire, la configuration livrée et les deux notices dans Program +# Files. Il ne copie AUCUN script : uninstall.ps1, update.ps1 et harden.ps1 ne survivaient +# jusqu'ici que parce que l'archive restait sur le Bureau. Un poste installé depuis +# %TEMP% n'aurait pas de désinstalleur, et TROUBLESHOOTING.md enverrait un bénévole +# chercher un fichier qui n'existe plus. +$installerHome = Join-Path (Join-Path $paths.DataRoot 'installer') $release.tag_name +if (Test-Path $installerHome) { Remove-Item -LiteralPath $installerHome -Recurse -Force } +New-Item -ItemType Directory -Path (Split-Path -Parent $installerHome) -Force | Out-Null +Move-Item -LiteralPath $extracted.FullName -Destination $installerHome -Force +Remove-Item -LiteralPath $workspace -Recurse -Force -ErrorAction Ignore + +Write-Host '' +Write-Host " Les scripts de ce poste (mise à jour, désinstallation, durcissement) sont dans :" +Write-Host " $installerHome" + +if ($Relaunched) { + Write-Host '' + Read-Host ' Appuyez sur Entrée pour fermer cette fenêtre' +} diff --git a/deploy/windows/common.ps1 b/deploy/windows/common.ps1 index a895b5e..26f121f 100644 --- a/deploy/windows/common.ps1 +++ b/deploy/windows/common.ps1 @@ -173,6 +173,34 @@ function New-RandomPassword { -join ($bytes | ForEach-Object { $alphabet[$_ % $alphabet.Length] }) } +function ConvertTo-PlainText { + <# + .SYNOPSIS + Le contenu d'un SecureString, en clair. + .DESCRIPTION + Le passage par Marshal est ce qui rend cette lecture possible sous WINDOWS POWERSHELL + 5.1 : « ConvertFrom-SecureString -AsPlainText » n'existe qu'à partir de PowerShell 7, + et un script d'installation qui ne tourne pas sur le PowerShell livré avec Windows ne + sert à rien. + + La mémoire non gérée est remise à zéro dans un finally, y compris si la lecture lève. + Ce que ça ne prétend PAS être, c'est une protection du mot de passe : celui-ci finit + en clair dans Winlogon\DefaultPassword et sur la fiche d'installation, et install.ps1 + assume les deux en toutes lettres. + #> + [CmdletBinding()] + param([Parameter(Mandatory)][securestring]$Secure) + + $pointer = [IntPtr]::Zero + try { + $pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($Secure) + [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer) + } + finally { + if ($pointer -ne [IntPtr]::Zero) { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer) } + } +} + function Save-Snapshot { <# .SYNOPSIS diff --git a/deploy/windows/install.ps1 b/deploy/windows/install.ps1 index 119b5f1..0e2fb09 100644 --- a/deploy/windows/install.ps1 +++ b/deploy/windows/install.ps1 @@ -41,6 +41,15 @@ n'est PAS en libre-service : sans elle, le poste reste sur l'écran de connexion de Windows après une coupure de courant, /healthz répond 200, et le poste est inutilisable. +.PARAMETER SessionPassword + Le mot de passe du compte Windows dédié, choisi par l'installateur. Absent, il est tiré + au sort sur 20 caractères — c'est le comportement par défaut, et le seul qu'ait connu ce + script jusqu'au 31/07/2026. + + Il est écrit en clair dans Winlogon\DefaultPassword quelques dizaines de lignes plus bas, + et sur la fiche d'installation : sa longueur n'est donc PAS ce qui protège ce poste. + C'est bootstrap.ps1 qui pose le plancher, à quatre caractères, et qui explique pourquoi. + .EXAMPLE .\install.ps1 .EXAMPLE @@ -50,6 +59,7 @@ param( [switch]$Pilot, [switch]$SkipAutoLogon, + [securestring]$SessionPassword, [string]$InstallDir, [string]$DataRoot) @@ -89,17 +99,36 @@ else { # --- 1. Compte local dédié, sans droits administrateur ------------------------------ # ★ AVANT l'ACL de l'étape 2, qui le nomme. -$password = New-RandomPassword 20 +$password = if ($SessionPassword) { ConvertTo-PlainText $SessionPassword } else { New-RandomPassword 20 } +$origin = if ($SessionPassword) { 'choisi à l''installation' } else { 'tiré au sort' } $secure = ConvertTo-SecureString $password -AsPlainText -Force -if (Get-LocalUser -Name $script:AccountName -ErrorAction Ignore) { - Set-LocalUser -Name $script:AccountName -Password $secure -PasswordNeverExpires $true - Write-Step "compte local $($script:AccountName) : mot de passe renouvelé" $paths.LogFile + +# La LONGUEUR et l'ORIGINE, jamais la valeur : ce journal reste sur le poste, la fiche part +# au classeur. C'est la même règle que pour le code de secours, deux étapes plus bas. +Write-Step "mot de passe de session : $($password.Length) caractères, $origin" $paths.LogFile + +# Le try/catch nomme LA cause. Une stratégie locale peut imposer une longueur ou une +# complexité minimale (net accounts) : hors domaine elle vaut zéro, mais quand elle refuse, +# New-LocalUser lève une exception qui parle de « mot de passe ne satisfait pas aux +# exigences » sans dire laquelle, ni où la lire. +try { + if (Get-LocalUser -Name $script:AccountName -ErrorAction Ignore) { + Set-LocalUser -Name $script:AccountName -Password $secure -PasswordNeverExpires $true + Write-Step "compte local $($script:AccountName) : mot de passe renouvelé" $paths.LogFile + } + else { + New-LocalUser -Name $script:AccountName -Password $secure -PasswordNeverExpires ` + -AccountNeverExpires -FullName 'Poste de pesée OpenScale' ` + -Description 'Compte du kiosque. Sans droits administrateur.' | Out-Null + Write-Step "compte local $($script:AccountName) créé" $paths.LogFile + } } -else { - New-LocalUser -Name $script:AccountName -Password $secure -PasswordNeverExpires ` - -AccountNeverExpires -FullName 'Poste de pesée OpenScale' ` - -Description 'Compte du kiosque. Sans droits administrateur.' | Out-Null - Write-Step "compte local $($script:AccountName) créé" $paths.LogFile +catch [Microsoft.PowerShell.Commands.InvalidPasswordException] { + throw "Windows a refusé ce mot de passe pour le compte $($script:AccountName) : la " + + "stratégie locale de ce PC en exige un plus long ou plus complexe. « net accounts » " + + 'affiche la longueur minimale exigée, et secpol.msc la complexité. Relancez ensuite ' + + "l'installation avec un mot de passe qui la respecte, ou sans mot de passe du tout — " + + "il sera alors tiré au sort sur 20 caractères. ($($_.Exception.Message))" } # Le groupe des utilisateurs ordinaires, par son SID : « Utilisateurs » sur un Windows # français, « Users » sur un anglais, et un installeur qui nomme le groupe en clair diff --git a/handbook/getting-started.md b/handbook/getting-started.md index ad743eb..d5e29e0 100644 --- a/handbook/getting-started.md +++ b/handbook/getting-started.md @@ -215,3 +215,22 @@ la pesée : le catalogue vit en mémoire. [`docs/05-demarrage-rapide.md`](https://github.com/lostmind84/OpenScale/blob/main/docs/05-demarrage-rapide.md) - Installer un **vrai** poste, écran tactile et imprimante compris : [`INSTALLATION.md`](https://github.com/lostmind84/OpenScale/blob/main/INSTALLATION.md) + +## Installer un poste de production + +Rien de ce qui précède n'est nécessaire : un poste s'installe en une commande, sur un +Windows nu, sans dépôt, sans Go et sans archive à décompresser. Depuis PowerShell — les +droits administrateur sont demandés en cours de route : + +```powershell +irm https://raw.githubusercontent.com/lostmind84/OpenScale/main/deploy/windows/bootstrap.ps1 | iex +``` + +La commande prend la dernière version publiée, **vérifie son empreinte avant de la +décompresser**, pose trois questions — mot de passe de la session du poste, production ou +pilote, ouverture de session automatique — puis déroule l'installation complète : compte +Windows dédié, service, tâche du kiosque, réglages d'alimentation, fiche d'installation. + +Le détail, la voie hors ligne par clé USB et la suite du parcours (redémarrage de recette, +balance, imprimante, catalogue) sont dans +[`INSTALLATION.md`](https://github.com/lostmind84/OpenScale/blob/main/INSTALLATION.md). From ace1687bbf54cc5439264cf05444ab42c8044b48 Mon Sep 17 00:00:00 2001 From: "Fabien F." Date: Fri, 31 Jul 2026 13:22:46 +0200 Subject: [PATCH 3/4] docs(bootstrap): l'invite du mot de passe se contredisait en deux lignes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit « Vide = tiré au sort (20 caractères) » puis, deux lignes plus bas, « laisser vide ne touche pas au mot de passe en place ». Les deux sont vraies, mais pas au même moment, et l'écran ne disait pas lequel : c'est install.ps1 qui décide, selon que le poste est neuf ou déjà installé. L'invite le dit maintenant en une seule phrase, et nomme au passage ce que la saisie masquée achète. --- deploy/windows/bootstrap.ps1 | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/deploy/windows/bootstrap.ps1 b/deploy/windows/bootstrap.ps1 index 24164e3..7422d7b 100644 --- a/deploy/windows/bootstrap.ps1 +++ b/deploy/windows/bootstrap.ps1 @@ -345,9 +345,10 @@ if (-not $Yes -and -not $AccountPassword) { # demander une confirmation — personne ne peut ouvrir la session d'un poste dont le mot # de passe a été tapé de travers. Write-Host " Mot de passe de la session Windows « $script:AccountName »" - Write-Host " $script:MinimumPasswordLength caractères minimum. Vide = tiré au sort (20 caractères)." + Write-Host " $script:MinimumPasswordLength caractères minimum, et il ne s'affiche pas pendant que vous le tapez." Write-Host ' Il sera imprimé sur la fiche d''installation, à ranger dans le classeur.' - Write-Host ' Sur un poste DÉJÀ installé, laisser vide ne touche pas au mot de passe en place.' + Write-Host ' Laissé VIDE, l''installeur décide : il en tire un de 20 caractères sur un poste' + Write-Host ' neuf, et garde celui en place sur un poste déjà installé.' while ($true) { $first = ConvertTo-PlainText (Read-Host ' Mot de passe' -AsSecureString) if ($first -eq '') { From 44cc427a2280e3629d540c16100836f03d3b41eb Mon Sep 17 00:00:00 2001 From: "Fabien F." Date: Fri, 31 Jul 2026 13:27:45 +0200 Subject: [PATCH 4/4] docs(readme): installer un poste passe avant l'essayer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit « Installer un poste » venait en septième section, après « Développer » : un dépôt dont le produit est un poste de pesée le décrivait à qui vient le compiler, pas à qui vient le poser dans un magasin. La commande unique est maintenant la première chose sous le titre, et la démonstration sans matériel la suit. --- README.md | 56 +++++++++++++++++++++++++++---------------------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index c96d3c1..b378d6c 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,34 @@ la caisse, la géométrie de l'étiquette — mais aucune ligne de code. en service.** Il reste la recette sur un poste pilote. Ce qui est prouvé, ce qui ne l'est pas et ce qui reste ouvert : [SUIVI.md](SUIVI.md). +## Installer un poste + +Sur un Windows nu, sans dépôt, sans Go et sans archive à décompresser. Les droits +administrateur sont demandés en cours de route : + +```powershell +irm https://raw.githubusercontent.com/lostmind84/OpenScale/main/deploy/windows/bootstrap.ps1 | iex +``` + +
+Depuis une invite de commandes (cmd) + +```cmd +curl -fsSL https://raw.githubusercontent.com/lostmind84/OpenScale/main/deploy/windows/bootstrap.cmd -o %TEMP%\openscale.cmd && %TEMP%\openscale.cmd +``` + +
+ +La commande prend la dernière version publiée, **vérifie son empreinte avant de la +décompresser**, pose trois questions — mot de passe de la session du poste, production ou +pilote, ouverture de session automatique — puis installe tout : compte Windows dédié, +service, tâche du kiosque, réglages d'alimentation, fiche d'installation à ranger dans le +classeur du magasin. Comptez **15 minutes** jusqu'à la première étiquette, matériel branché. + +Le parcours complet du bénévole — redémarrage de recette, balance, imprimante, catalogue, +et **l'installation par clé USB d'un poste sans Internet** — est dans +[`INSTALLATION.md`](INSTALLATION.md). Sous Linux, `deploy/linux/install.sh`. + ## Essayer, sans balance et sans imprimante Un poste complet tourne sur votre machine en quatre commandes. **Les deux colonnes font @@ -124,34 +152,6 @@ Code et commentaires en **anglais**, documentation et messages utilisateur en détecteur de course sont détaillés dans [`docs/06-developpement.md`](docs/06-developpement.md). -## Installer un poste - -Sur un Windows nu, sans dépôt, sans Go et sans archive à décompresser — les droits -administrateur sont demandés en cours de route : - -```powershell -irm https://raw.githubusercontent.com/lostmind84/OpenScale/main/deploy/windows/bootstrap.ps1 | iex -``` - -
-Depuis une invite de commandes (cmd) - -```cmd -curl -fsSL https://raw.githubusercontent.com/lostmind84/OpenScale/main/deploy/windows/bootstrap.cmd -o %TEMP%\openscale.cmd && %TEMP%\openscale.cmd -``` - -
- -La commande prend la dernière version publiée, **vérifie son empreinte avant de la -décompresser**, pose trois questions — mot de passe de la session du poste, production ou -pilote, ouverture de session automatique — puis installe tout : compte Windows dédié, -service, tâche du kiosque, réglages d'alimentation, fiche d'installation à ranger dans le -classeur du magasin. - -Le parcours complet du bénévole — redémarrage de recette, balance, imprimante, catalogue, -et **l'installation par clé USB d'un poste sans Internet** — est dans -[`INSTALLATION.md`](INSTALLATION.md). - ## Déployer Un poste ne s'installe pas en copiant `openscale.exe` : il lui faut les scripts, la tâche