Compare commits
29
Commits
dev/1.2
...
admin/phase1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
920d215fdd | ||
|
|
22468122e0 | ||
|
|
c2c67a63f2 | ||
|
|
d5aa875178 | ||
|
|
b9fea61e64 | ||
|
|
14a76927e1 | ||
|
|
5e68c4b16a | ||
|
|
e5b9ebf3ff | ||
|
|
fecf95ae3e | ||
|
|
3a788c18d2 | ||
|
|
dbe5d9051d | ||
|
|
ae2a5a4ab7 | ||
|
|
e8e08f792e | ||
|
|
c41bcda853 | ||
|
|
e41c3a977e | ||
|
|
f5e6c99aca | ||
|
|
35664045ce | ||
|
|
ad5d92ee8b | ||
|
|
047ec0d1db | ||
|
|
6243bd2574 | ||
|
|
7ee4d84dd2 | ||
|
|
f4ebb680ef | ||
|
|
756a76582d | ||
|
|
b5b922019c | ||
|
|
7ee8da3e67 | ||
|
|
0e8c918922 | ||
|
|
e2f2ae5817 | ||
|
|
9dda708c94 | ||
|
|
a17f0efcd7 |
No files matched your search
@@ -61,3 +61,10 @@
|
||||
#*.PDF diff=astextplain
|
||||
#*.rtf diff=astextplain
|
||||
#*.RTF diff=astextplain
|
||||
|
||||
###############################################################################
|
||||
# Fichiers executes sur le VPS (Linux) : fins de ligne LF obligatoires
|
||||
###############################################################################
|
||||
*.sh text eol=lf
|
||||
*.service text eol=lf
|
||||
server/deploy/*.conf text eol=lf
|
||||
@@ -412,3 +412,8 @@ FodyWeavers.xsd
|
||||
# Clé PRIVÉE de signature des mises à jour du launcher : ne jamais versionner
|
||||
launcher-signing.key
|
||||
*.download
|
||||
|
||||
# Cles privees (signature du launcher, administrateurs) : jamais dans Git
|
||||
*.key
|
||||
# Donnees locales de la Launcher API (F5)
|
||||
data-dev/
|
||||
@@ -28,9 +28,34 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
|
||||
server-example\launcher\publier-launcher.txt = server-example\launcher\publier-launcher.txt
|
||||
server-example\launcher\preparer-vps.sh = server-example\launcher\preparer-vps.sh
|
||||
README.md = README.md
|
||||
docs\ADMIN-ARCHITECTURE.md = docs\ADMIN-ARCHITECTURE.md
|
||||
tools\admin-key.bat = tools\admin-key.bat
|
||||
tools\publish-server.bat = tools\publish-server.bat
|
||||
server-example\web.config = server-example\web.config
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "server", "server", "{E23AAB8F-1D5C-41C9-AFA3-4246B035CD4C}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "deploy", "deploy", "{1F9FBD8B-7A86-4F7B-A2ED-E18F5B014CDF}"
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
server\deploy\LISEZMOI.md = server\deploy\LISEZMOI.md
|
||||
server\deploy\installer-api.sh = server\deploy\installer-api.sh
|
||||
server\deploy\launcher-api.service = server\deploy\launcher-api.service
|
||||
server\deploy\nginx-api.conf = server\deploy\nginx-api.conf
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{266A4FF5-5752-46BA-803C-09E351720DB2}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Contracts", "src\GameLauncher.Contracts\GameLauncher.Contracts.csproj", "{99F6893A-AB86-4F7B-8F62-2D09CFCE8342}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.ApiClient", "src\GameLauncher.ApiClient\GameLauncher.ApiClient.csproj", "{3BB233EF-1252-4D1D-9808-9C75EB393065}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Server", "server\GameLauncher.Server\GameLauncher.Server.csproj", "{F4BAC0CC-DA96-481F-BD2A-35E7E156652E}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AdminKeyTool", "tools\AdminKeyTool\AdminKeyTool.csproj", "{CC6B01D4-258E-4552-BBB4-1D3CA8E2BE55}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Tests", "tests\GameLauncher.Tests\GameLauncher.Tests.csproj", "{DBAE3BFF-DAA8-4BB0-BDA3-3F31AC5C542F}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -71,6 +96,46 @@ Global
|
||||
{B6C1E2D3-4F5A-4B6C-9D7E-8F9A0B1C2D3E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B6C1E2D3-4F5A-4B6C-9D7E-8F9A0B1C2D3E}.Release|x64.ActiveCfg = Release|x64
|
||||
{B6C1E2D3-4F5A-4B6C-9D7E-8F9A0B1C2D3E}.Release|x64.Build.0 = Release|x64
|
||||
{99F6893A-AB86-4F7B-8F62-2D09CFCE8342}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{99F6893A-AB86-4F7B-8F62-2D09CFCE8342}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{99F6893A-AB86-4F7B-8F62-2D09CFCE8342}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{99F6893A-AB86-4F7B-8F62-2D09CFCE8342}.Debug|x64.Build.0 = Debug|x64
|
||||
{99F6893A-AB86-4F7B-8F62-2D09CFCE8342}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{99F6893A-AB86-4F7B-8F62-2D09CFCE8342}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{99F6893A-AB86-4F7B-8F62-2D09CFCE8342}.Release|x64.ActiveCfg = Release|x64
|
||||
{99F6893A-AB86-4F7B-8F62-2D09CFCE8342}.Release|x64.Build.0 = Release|x64
|
||||
{3BB233EF-1252-4D1D-9808-9C75EB393065}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{3BB233EF-1252-4D1D-9808-9C75EB393065}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{3BB233EF-1252-4D1D-9808-9C75EB393065}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{3BB233EF-1252-4D1D-9808-9C75EB393065}.Debug|x64.Build.0 = Debug|x64
|
||||
{3BB233EF-1252-4D1D-9808-9C75EB393065}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{3BB233EF-1252-4D1D-9808-9C75EB393065}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{3BB233EF-1252-4D1D-9808-9C75EB393065}.Release|x64.ActiveCfg = Release|x64
|
||||
{3BB233EF-1252-4D1D-9808-9C75EB393065}.Release|x64.Build.0 = Release|x64
|
||||
{F4BAC0CC-DA96-481F-BD2A-35E7E156652E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{F4BAC0CC-DA96-481F-BD2A-35E7E156652E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{F4BAC0CC-DA96-481F-BD2A-35E7E156652E}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{F4BAC0CC-DA96-481F-BD2A-35E7E156652E}.Debug|x64.Build.0 = Debug|x64
|
||||
{F4BAC0CC-DA96-481F-BD2A-35E7E156652E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{F4BAC0CC-DA96-481F-BD2A-35E7E156652E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{F4BAC0CC-DA96-481F-BD2A-35E7E156652E}.Release|x64.ActiveCfg = Release|x64
|
||||
{F4BAC0CC-DA96-481F-BD2A-35E7E156652E}.Release|x64.Build.0 = Release|x64
|
||||
{CC6B01D4-258E-4552-BBB4-1D3CA8E2BE55}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{CC6B01D4-258E-4552-BBB4-1D3CA8E2BE55}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{CC6B01D4-258E-4552-BBB4-1D3CA8E2BE55}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{CC6B01D4-258E-4552-BBB4-1D3CA8E2BE55}.Debug|x64.Build.0 = Debug|x64
|
||||
{CC6B01D4-258E-4552-BBB4-1D3CA8E2BE55}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{CC6B01D4-258E-4552-BBB4-1D3CA8E2BE55}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{CC6B01D4-258E-4552-BBB4-1D3CA8E2BE55}.Release|x64.ActiveCfg = Release|x64
|
||||
{CC6B01D4-258E-4552-BBB4-1D3CA8E2BE55}.Release|x64.Build.0 = Release|x64
|
||||
{DBAE3BFF-DAA8-4BB0-BDA3-3F31AC5C542F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{DBAE3BFF-DAA8-4BB0-BDA3-3F31AC5C542F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{DBAE3BFF-DAA8-4BB0-BDA3-3F31AC5C542F}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{DBAE3BFF-DAA8-4BB0-BDA3-3F31AC5C542F}.Debug|x64.Build.0 = Debug|x64
|
||||
{DBAE3BFF-DAA8-4BB0-BDA3-3F31AC5C542F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{DBAE3BFF-DAA8-4BB0-BDA3-3F31AC5C542F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{DBAE3BFF-DAA8-4BB0-BDA3-3F31AC5C542F}.Release|x64.ActiveCfg = Release|x64
|
||||
{DBAE3BFF-DAA8-4BB0-BDA3-3F31AC5C542F}.Release|x64.Build.0 = Release|x64
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -80,6 +145,12 @@ Global
|
||||
{7384678B-D7F7-4682-B99D-2DDCBC605BA4} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
|
||||
{35F7D646-FAC8-46A6-ACC8-0E22AE78B603} = {6EFB114A-05DF-49C9-B0EF-26BDF7EFE881}
|
||||
{B6C1E2D3-4F5A-4B6C-9D7E-8F9A0B1C2D3E} = {6EFB114A-05DF-49C9-B0EF-26BDF7EFE881}
|
||||
{99F6893A-AB86-4F7B-8F62-2D09CFCE8342} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
|
||||
{3BB233EF-1252-4D1D-9808-9C75EB393065} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
|
||||
{F4BAC0CC-DA96-481F-BD2A-35E7E156652E} = {E23AAB8F-1D5C-41C9-AFA3-4246B035CD4C}
|
||||
{CC6B01D4-258E-4552-BBB4-1D3CA8E2BE55} = {6EFB114A-05DF-49C9-B0EF-26BDF7EFE881}
|
||||
{DBAE3BFF-DAA8-4BB0-BDA3-3F31AC5C542F} = {266A4FF5-5752-46BA-803C-09E351720DB2}
|
||||
{1F9FBD8B-7A86-4F7B-A2ED-E18F5B014CDF} = {E23AAB8F-1D5C-41C9-AFA3-4246B035CD4C}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {E2F3A4B5-C6D7-4E8F-9A0B-1C2D3E4F5A6B}
|
||||
|
||||
@@ -35,6 +35,11 @@ Vous obtenez un `GameLauncher.exe` autonome (aucun runtime à installer) + `laun
|
||||
| `src/GameLauncher.Core` | Logique pure, sans WPF : manifest, téléchargement, installation, mise à jour, vérification, désinstallation |
|
||||
| `tools/ManifestBuilder` | Outil console de publication : génère `manifest.json`, l'archive ZIP et le dossier `objects/` |
|
||||
| `tools/LauncherPublisher` | Outil console de publication **du launcher** : clés de signature, `launcher-update.json` signé (voir §7) |
|
||||
| `src/GameLauncher.Contracts` | Protocole partagé launcher ⇄ Launcher API : routes, échanges, rôles, messages signés (voir §8) |
|
||||
| `src/GameLauncher.ApiClient` | Client de la Launcher API : connexion par clé, requêtes signées, fichier de clé chiffré |
|
||||
| `server/GameLauncher.Server` | **Launcher API** (ASP.NET Core) : serveur d'administration, déployé sur le VPS (voir §8) |
|
||||
| `tools/AdminKeyTool` | Clé d'administrateur et gestion des administrateurs en ligne de commande (`tools\admin-key.bat`) |
|
||||
| `tests/GameLauncher.Tests` | Tests automatisés (xUnit) : Test › Explorateur de tests › Exécuter tout |
|
||||
|
||||
```
|
||||
src/GameLauncher/
|
||||
@@ -43,13 +48,13 @@ src/GameLauncher/
|
||||
├─ Assets/ background.png (thème par défaut), Themes/exovival-background.jpg, launcher.ico
|
||||
├─ Themes/ Default.xaml et Exovival.xaml (thèmes), Icons.xaml (icônes), Controls.xaml (styles),
|
||||
│ Changelog.xaml (modèles des notes de version)
|
||||
├─ Views/ MainWindow (+ liste des jeux), InstallDialog, SettingsWindow, ChangelogWindow, ModernDialog
|
||||
├─ Views/ MainWindow (+ liste des jeux), InstallDialog, SettingsWindow, ChangelogWindow, ModernDialog, TrayMenuWindow
|
||||
├─ ViewModels/ LauncherViewModel (jeux, thème actif, paramètres du launcher),
|
||||
│ GameViewModel (machine à états d'un jeu),
|
||||
│ LauncherSettingsViewModel / GameSettingsViewModel (les deux fenêtres de paramètres)
|
||||
├─ Services/ ThemeManager, RegistryService, ShortcutService, InstanceChannel (raccourci → launcher
|
||||
│ déjà ouvert), GameScopedServices, interfaces
|
||||
└─ Platform/ DialogService, WindowService (implémentations WPF)
|
||||
└─ Platform/ DialogService, WindowService (implémentations WPF), TrayIcon (zone de notification)
|
||||
```
|
||||
|
||||
## 3. Publier une version du jeu (ManifestBuilder)
|
||||
@@ -161,6 +166,13 @@ Le raccourci créé à l'installation pointe vers **le launcher** (`GameLauncher
|
||||
pas vers le jeu : les mises à jour sont ainsi toujours vérifiées. Un raccourci « du jeu » est
|
||||
reconnu à ses arguments `--game <GameId>`, même si le joueur l'a renommé (Bureau de l'utilisateur).
|
||||
|
||||
* **Créer le raccourci** : à l'installation (case à cocher), ou à tout moment depuis le menu **« … »** ›
|
||||
*Paramètres du jeu* › bloc *Raccourci* (« Créer le raccourci », ou « Mettre à jour » s'il existe déjà).
|
||||
* **Icône** : celle de l'exécutable du jeu (dossier d'installation et exécutable lus dans le registre),
|
||||
ou celle du launcher si le jeu n'est pas installé ou introuvable. Elle suit le jeu s'il est déplacé
|
||||
(localisation, réparation) ; les raccourcis créés par une version précédente prennent l'icône du jeu
|
||||
au démarrage du launcher. Une icône choisie par le joueur n'est jamais remplacée.
|
||||
|
||||
| Situation | Comportement |
|
||||
|---|---|
|
||||
| Raccourci ouvert, launcher **fermé** | Le launcher s'ouvre sur ce jeu, vérifie les mises à jour (et les installe si la mise à jour automatique du jeu est activée), puis **lance le jeu** s'il est prêt, en appliquant le réglage *Lancement du jeu* (réduire / fermer le launcher). Sinon le bandeau explique pourquoi (mise à jour manuelle à faire, réparation, jeu non installé). Hors ligne, le jeu installé est lancé. |
|
||||
@@ -168,7 +180,8 @@ reconnu à ses arguments `--game <GameId>`, même si le joueur l'a renommé (Bur
|
||||
| Raccourci vers un **autre exemplaire** du launcher, encore présent (copie) | Rien d'automatique ; **Réparer** propose de le rediriger (confirmation) |
|
||||
| Icône personnalisée par le joueur | Conservée lors de la redirection |
|
||||
| Raccourci ouvert alors que le launcher est **déjà ouvert** | La demande est transmise au launcher ouvert : il revient au premier plan sur ce jeu et **lance le jeu** s'il est prêt et pas déjà en cours d'exécution (sinon le bandeau explique pourquoi : mise à jour, réparation, opération en cours…). Rien n'est lancé si une boîte de dialogue est ouverte. |
|
||||
| Launcher relancé **sans** `--game` alors qu'il est déjà ouvert | Message « Le launcher est déjà ouvert. » (inchangé) |
|
||||
| Launcher relancé **sans** `--game` alors qu'il est déjà ouvert | Message « Le launcher est déjà ouvert » (qui rappelle de regarder dans la zone de notification) |
|
||||
| Raccourci ouvert alors que le launcher est **réduit dans la zone de notification** | La fenêtre est réaffichée, puis même comportement que « déjà ouvert » |
|
||||
|
||||
Tant que le launcher n'a pas été relancé depuis son nouvel emplacement, un raccourci vers l'ancien
|
||||
emplacement ne fonctionne pas (Windows le signale, et le retrouve parfois tout seul). Le chemin du
|
||||
@@ -193,17 +206,51 @@ version, une mise à jour (différentielle) est proposée.
|
||||
### Désinstallation
|
||||
Depuis le menu « … » ou depuis **Paramètres Windows › Applications installées** (le launcher est
|
||||
relancé avec `--uninstall <GameId>`). Par défaut seuls les fichiers installés par le launcher sont
|
||||
supprimés ; une option permet de supprimer tout le dossier (refusé pour les dossiers sensibles :
|
||||
racine de disque, Program Files, Documents…).
|
||||
supprimés : les sauvegardes et réglages du jeu (`%APPDATA%\<Société>\<Produit>`) et les fichiers
|
||||
personnels du dossier d'installation sont conservés.
|
||||
|
||||
L'option **« Supprimer aussi les sauvegardes, les réglages, les journaux et tous les autres fichiers du jeu »**
|
||||
(proposée aussi quand les fichiers du jeu ont déjà disparu) supprime en plus :
|
||||
|
||||
| Élément | Emplacement |
|
||||
|---|---|
|
||||
| Tout le dossier d'installation | refusé pour les dossiers sensibles : racine de disque, Program Files, Documents… |
|
||||
| Sauvegardes et réglages | `%APPDATA%\LanisysGames\Exovival Zombie Reborn` |
|
||||
| Journaux de Unity (`Player.log`) | `%USERPROFILE%\AppData\LocalLow\LanisysGames\Exovival Zombie Reborn` |
|
||||
| Réglages d'affichage de Unity (résolution, plein écran, position de la fenêtre) | `HKCU\Software\LanisysGames\Exovival Zombie Reborn` |
|
||||
|
||||
Garde-fous : chemin « Société\Produit » complet uniquement (`gameDataFolder`, jamais le dossier de la
|
||||
société entier), jamais le dossier ni la clé du launcher (`GameLauncher`) ni le dossier d'installation ;
|
||||
dossier et clé de la société retirés seulement s'ils sont vides. Ce qui n'a pas pu être supprimé (fichier
|
||||
ouvert) est indiqué dans le bandeau et le journal.
|
||||
|
||||
**Launcher placé dans un dossier supprimé** — dossier du jeu, ou (avec l'option) dossier des sauvegardes dans
|
||||
Roaming ou des journaux dans LocalLow, à toute profondeur : la confirmation l'indique (« Le launcher se trouve
|
||||
dans ce dossier / dans le dossier des données du jeu : il sera conservé. ») et ses fichiers sont épargnés :
|
||||
son exécutable, `launcher.json`, les fichiers temporaires de sa mise à jour (`GameLauncher.exe.old`,
|
||||
`.download`…) et, en version de développement, les bibliothèques chargées depuis son dossier. Tout le reste
|
||||
est supprimé, les sous-dossiers vides sont retirés, et le bandeau final indique où le launcher a été conservé.
|
||||
Les trois dossiers suivent la même règle (`FileUtil.DeleteDirectoryContents`).
|
||||
|
||||
**Fichier bloqué** (jeu mal fermé, antivirus…) : la désinstallation ne s'interrompt plus. Les fichiers sont
|
||||
supprimés un par un ; ceux qui ne peuvent pas l'être sont signalés (bandeau + liste complète dans le journal),
|
||||
puis registre, raccourci et données du jeu sont traités normalement.
|
||||
|
||||
### Registre (HKEY_CURRENT_USER, aucun droit administrateur requis)
|
||||
```
|
||||
HKCU\Software\<Publisher>\<GameId>
|
||||
InstallPath, Version, Executable, InstallDate, LauncherPath
|
||||
HKCU\Software\<Publisher>\GameLauncher (ex. …\LanisysGames\GameLauncher)
|
||||
LauncherPath emplacement du launcher (une seule fois)
|
||||
HKCU\Software\<Publisher>\GameLauncher\Games\<GameId> (ex. …\GameLauncher\Games\EZR)
|
||||
InstallPath, Version, Executable, InstallDate
|
||||
HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\<Publisher>.<GameId>
|
||||
DisplayName, DisplayVersion, Publisher, InstallLocation, DisplayIcon,
|
||||
UninstallString ("GameLauncher.exe" --uninstall <GameId>), EstimatedSize…
|
||||
```
|
||||
* `LauncherPath` est écrit une seule fois pour tous les jeux, à l'installation d'un jeu et à chaque démarrage
|
||||
(s'il y a au moins un jeu installé), et supprimé à la désinstallation du dernier jeu. Seul le launcher
|
||||
réellement ouvert l'écrit : un second launcher, arrêté par « Le launcher est déjà ouvert » (ou qui transmet
|
||||
un raccourci au launcher ouvert), ne touche pas au registre. Il est informatif : la vérification des
|
||||
raccourcis compare la cible de chaque raccourci à l'exécutable en cours, pas au registre.
|
||||
|
||||
### Paramètres
|
||||
|
||||
@@ -211,19 +258,65 @@ Deux fenêtres distinctes :
|
||||
|
||||
| Accès | Contenu | Portée |
|
||||
|---|---|---|
|
||||
| **Roue crantée** (en haut à droite) › *Paramètres du launcher* | Mise à jour automatique du launcher, « Vérifier les mises à jour », bloc *Lancement du jeu* (réduire / fermer le launcher pendant la partie), dossier des journaux | Commun à tous les jeux |
|
||||
| Menu **« … »** › *Paramètres du jeu* | Mises à jour automatiques du jeu, bloc *Téléchargement* (téléchargements simultanés, vérification après installation), bloc *Informations* (versions, taille, dossier du jeu, journaux) | Jeu sélectionné uniquement |
|
||||
| **Roue crantée** (en haut à droite) › *Paramètres du launcher* | Mise à jour automatique du launcher, « Vérifier les mises à jour », bloc *Lancement du jeu* (réduire — dans la barre des tâches ou la zone de notification — ou fermer le launcher pendant la partie), bloc *Fermeture du launcher* (la croix ferme ou réduit dans la zone de notification), dossier des journaux | Commun à tous les jeux |
|
||||
| Menu **« … »** › *Paramètres du jeu* | Mises à jour automatiques du jeu, bloc *Téléchargement* (téléchargements simultanés, vérification après installation), bloc *Raccourci* (créer / mettre à jour le raccourci du Bureau), bloc *Informations* (versions, taille ; boutons *dossier du jeu*, *dossier des journaux* du jeu dans LocalLow, *dossier des données* du jeu dans Roaming) | Jeu sélectionné uniquement |
|
||||
|
||||
Au premier démarrage d'une version qui sépare les paramètres, les réglages *Lancement du jeu*
|
||||
sont repris de ceux du jeu affiché par défaut.
|
||||
|
||||
### Zone de notification (icônes cachées, près de l'horloge)
|
||||
|
||||
Deux options des *Paramètres du launcher* (désactivées par défaut) :
|
||||
|
||||
| Option | Effet |
|
||||
|---|---|
|
||||
| *Lancement du jeu* › Réduire le launcher pendant la partie › **Dans la zone de notification** | Pendant la partie, la fenêtre est masquée et une icône apparaît près de l'horloge ; la fenêtre revient à la fermeture du jeu |
|
||||
| *Fermeture du launcher* › **La croix réduit le launcher dans la zone de notification** | La croix (et Alt+F4) masque la fenêtre au lieu de quitter : téléchargements, mises à jour et surveillance du jeu continuent |
|
||||
|
||||
L'icône n'est affichée que lorsque la fenêtre est masquée. **Clic gauche** : réouvre le launcher.
|
||||
**Clic droit** : menu *Ouvrir* / *Quitter* (même style arrondi que les autres menus, couleurs du thème
|
||||
du jeu affiché). *Quitter* demande confirmation si une opération est en cours (la fenêtre est alors
|
||||
réaffichée). À la fermeture de session ou à l'arrêt de Windows, le launcher se ferme normalement.
|
||||
L'icône est gérée par l'API Windows (`Shell_NotifyIcon`, `Platform/TrayIcon.cs`), sans Windows Forms,
|
||||
et réapparaît si l'Explorateur Windows redémarre. Le menu est une petite fenêtre (`Views/TrayMenuWindow`)
|
||||
et non un `ContextMenu` WPF : launcher masqué, un `ContextMenu` ne peut pas capturer la souris et se
|
||||
referme aussitôt. Il se ferme dès qu'il perd le premier plan (clic ailleurs) ou avec Échap.
|
||||
|
||||
### Autres fichiers
|
||||
* Paramètres d'un jeu : `%AppData%\<Publisher>\<GameId>Launcher\settings.json`
|
||||
* Paramètres du launcher (communs) : `%AppData%\<Publisher>\<LauncherId>\launcher-settings.json`
|
||||
(en mono-jeu, `<LauncherId>` = `<GameId>Launcher` : même dossier que ci-dessus)
|
||||
* Journaux : `%AppData%\<Publisher>\<GameId>Launcher\logs\launcher.log` (accessibles depuis les deux fenêtres de paramètres) ;
|
||||
en multi-jeux : `%AppData%\<Publisher>\<launcherId>\logs\` (+ `launcher-state.json` : dernier jeu affiché)
|
||||
```
|
||||
%AppData%\<Publisher>\GameLauncher (ex. %AppData%\LanisysGames\GameLauncher)
|
||||
├─ launcher-settings.json paramètres du launcher (communs à tous les jeux)
|
||||
├─ self-update.json mise à jour du launcher (§7)
|
||||
├─ launcher-state.json dernier jeu affiché (plusieurs jeux)
|
||||
├─ logs\launcher.log journal du launcher (roue crantée › Ouvrir le dossier des journaux)
|
||||
└─ Games\<GameId>\settings.json paramètres de chaque jeu (menu « … » › Paramètres du jeu)
|
||||
```
|
||||
* État de l'installation : `<dossier du jeu>\.launcher\state.json` (dossier caché)
|
||||
* Nom du dossier et de la clé de registre : `launcher.json` → `"dataFolder"` (à la racine ; `"GameLauncher"`
|
||||
par défaut). Il est indépendant de `launcherId`, qui ne doit plus changer (§7).
|
||||
|
||||
**Versions 1.2.1 et antérieures** : les données étaient dans `%AppData%\<Publisher>\<LauncherId>` (ex.
|
||||
`EZRLauncher`, + `<GameId>Launcher` par jeu en multi-jeux) et dans `HKCU\Software\<Publisher>\<GameId>`.
|
||||
Au démarrage, le launcher les déplace (renommage du dossier, paramètres de chaque jeu vers `Games\<GameId>`,
|
||||
copie vérifiée puis suppression de l'ancienne clé de registre). Ce déplacement n'a lieu que si aucun autre
|
||||
launcher n'est ouvert, et jamais au redémarrage qui suit une mise à jour du launcher (l'ancienne version doit
|
||||
pouvoir être remise en place) : l'ancien emplacement est alors utilisé pour la session et le déplacement est
|
||||
fait au démarrage suivant. Les entrées *Applications installées* (`Uninstall\<Publisher>.<GameId>`) ne changent pas.
|
||||
|
||||
### Données du jeu (Unity)
|
||||
|
||||
| Contenu | Emplacement | Bouton (*Paramètres du jeu* › *Informations*) |
|
||||
|---|---|---|
|
||||
| Sauvegardes (`Data\SavedGame`) et réglages (`Data\Config`) | `%APPDATA%\LanisysGames\Exovival Zombie Reborn` (Roaming, à côté de `GameLauncher`) | *Ouvrir le dossier des données* |
|
||||
| Journaux de Unity (`Player.log`, `Player-prev.log`) | `%USERPROFILE%\AppData\LocalLow\LanisysGames\Exovival Zombie Reborn` | *Ouvrir le dossier des journaux* |
|
||||
|
||||
* « LanisysGames\Exovival Zombie Reborn » correspond à *Company Name* / *Product Name* des Player Settings
|
||||
de Unity ; côté launcher : `launcher.json` → `gameDataFolder` (par défaut `publisher\gameName`).
|
||||
* Le jeu choisit l'emplacement dans `SerializationAsset.cs` (UHFPS, classe `ExovivalDataLocation`). Au
|
||||
premier lancement d'une version qui l'intègre, le dossier `Data` de LocalLow est copié vers Roaming,
|
||||
vérifié (mêmes fichiers, mêmes tailles), puis supprimé de LocalLow. En cas d'échec, le jeu continue
|
||||
d'utiliser LocalLow et réessaie au lancement suivant. Dans l'éditeur Unity, rien ne change (`Assets/Data`).
|
||||
* `Player.log` reste dans LocalLow : son emplacement est fixé par Unity (`Application.persistentDataPath`).
|
||||
|
||||
## 5. Personnalisation
|
||||
|
||||
@@ -237,6 +330,7 @@ sont repris de ceux du jeu affiché par défaut.
|
||||
| Icônes de l'interface | `Themes/Icons.xaml` (tracés 24×24, compatibles [Material Design Icons](https://pictogrammers.com/library/mdi/)) |
|
||||
| Textes | `launcher.json` (nom, accroche), `ViewModels/GameViewModel.cs` |
|
||||
| Fréquence des recherches de mises à jour | `launcher.json` → `updateCheckIntervalMinutes` |
|
||||
| Dossier des données du jeu Unity (« Société\Produit ») | `launcher.json` → `gameDataFolder` |
|
||||
|
||||
### Thèmes
|
||||
|
||||
@@ -367,5 +461,28 @@ launcher distribué : c'est l'identifiant de produit vérifié lors des mises à
|
||||
|
||||
### Fichiers
|
||||
|
||||
* Préférences : `%AppData%\<Publisher>\<LauncherId>\self-update.json` (`autoUpdate`, version en échec, dernière recherche)
|
||||
* Préférences : `%AppData%\<Publisher>\GameLauncher\self-update.json` (`autoUpdate`, version en échec, dernière recherche)
|
||||
* À côté de l'exe, temporairement : `GameLauncher.exe.<version>.download`, `GameLauncher.exe.old`, `GameLauncher.exe.replaced`, `GameLauncher.exe.failed`
|
||||
|
||||
## 8. Administration (Launcher API)
|
||||
|
||||
Chantier en cours, découpé en phases : architecture, sécurité et planning dans
|
||||
**`docs/ADMIN-ARCHITECTURE.md`**. Phase 1 livrée : le serveur d'administration et ses outils ;
|
||||
le menu d'administration du launcher arrive en phase 2 (rien ne change pour les joueurs d'ici là).
|
||||
|
||||
* **Principe** : le launcher étant public, la sécurité est assurée par le **serveur**. Chaque
|
||||
administrateur possède une **clé privée** (sur son PC, chiffrée) ; le serveur ne connaît que les
|
||||
clés publiques. Connexion par défi signé, sessions courtes, et **toute modification signée** par
|
||||
la clé (un jeton volé ne permet que de lire). Journal d'audit de chaque action.
|
||||
* **Rôles** : propriétaire (tout, dont la gestion des administrateurs), administrateur (contenu et
|
||||
publication), éditeur (contenu).
|
||||
* **Ajouter un administrateur** : invitation (code à usage unique, 24 h) → la personne enregistre sa
|
||||
clé → un propriétaire approuve en saisissant l'**empreinte** que la personne lui communique.
|
||||
Un nouveau PC d'un administrateur existant est approuvé par lui-même, depuis un PC déjà enregistré.
|
||||
|
||||
| Pour… | Où |
|
||||
|---|---|
|
||||
| Installer le serveur sur le VPS, créer le premier propriétaire, récupération | `server/deploy/LISEZMOI.md` |
|
||||
| Publier le serveur | `tools\publish-server.bat` (Linux x64 autonome + empreinte SHA-256) |
|
||||
| Créer sa clé, tester la connexion, inviter/approuver | `tools\admin-key.bat` (`help` pour la liste) |
|
||||
| Lancer le serveur en local (F5) | Projet `GameLauncher.Server`, profil *Development* : `http://127.0.0.1:5080/api/` |
|
||||
@@ -0,0 +1,345 @@
|
||||
# Module d'administration et socle modulaire — Architecture
|
||||
|
||||
> Document de référence du chantier « module d'administration ». Il fixe les décisions
|
||||
> d'architecture et de sécurité, puis découpe le travail en phases indépendantes.
|
||||
> Statut des phases : voir § 13.
|
||||
|
||||
## 1. Objectifs
|
||||
|
||||
* Un **menu d'administration intégré au launcher**, accessible par une entrée discrète, protégé par
|
||||
une **authentification par clé publique/privée**.
|
||||
* Depuis ce menu :
|
||||
* **éditer les notes de version existantes** avec un éditeur graphique qui respecte le format
|
||||
enrichi actuel (titre, sous-titre, introduction, sections, modifications, commentaires,
|
||||
étiquettes, sous-points, notes) ;
|
||||
* **publier une nouvelle version du jeu** (archive ZIP + `manifest.json` + dossier `objects/`)
|
||||
produite par l'outil `.bat` / ManifestBuilder, qui **reste l'outil de génération** ;
|
||||
* **modifier les informations des jeux** (nom affiché, accroche, description, liens, thème…),
|
||||
les jeux restant **déclarés dans `launcher.json`** (solution Visual Studio) ;
|
||||
* **gérer les administrateurs** (inviter, approuver, changer le rôle, révoquer).
|
||||
* Un **socle modulaire** : le menu d'administration est le premier module ; les fonctionnalités
|
||||
futures « à la Steam » (actualités, amis, succès, sauvegardes dans le cloud, boutique…) s'y
|
||||
brancheront de la même façon, côté launcher **et** côté serveur.
|
||||
|
||||
## 2. Principe de sécurité fondamental
|
||||
|
||||
Le launcher est distribué à tous les joueurs : **tout ce qu'il contient est public**, et toute
|
||||
vérification faite uniquement dans l'exécutable peut être contournée (exécutable modifié).
|
||||
|
||||
Conséquences :
|
||||
|
||||
1. **Le serveur est la seule barrière de sécurité.** Chaque action d'administration est vérifiée
|
||||
par le serveur (authentification, droits, validation du contenu). Le bouton caché n'est qu'un
|
||||
confort : le cacher ne protège rien.
|
||||
2. **Aucun secret dans l'exécutable.** L'exe ne contient que des éléments publics : adresses,
|
||||
clés *publiques*.
|
||||
3. **Le serveur ne stocke aucun secret utilisable** : uniquement des clés publiques. Une fuite
|
||||
complète de ses données ne permet pas de se connecter.
|
||||
4. **Toute modification est signée par la clé de l'administrateur** (en plus de la session) : un
|
||||
jeton de session volé ne permet que de lire.
|
||||
5. **Le serveur valide tout ce qu'il écrit** avec le même code que le launcher
|
||||
(`GameLauncher.Core`) et écrit de façon atomique (fichier temporaire puis renommage), avec
|
||||
sauvegarde de la version précédente et journal d'audit.
|
||||
6. Les éléments qui touchent à la sécurité ou au disque du joueur (URL des serveurs, clés de
|
||||
confiance, dossiers supprimés à la désinstallation…) **restent dans l'exe** et ne peuvent pas
|
||||
être modifiés depuis le serveur, ni par un `launcher.json` posé à côté de l'exe.
|
||||
|
||||
## 3. Vue d'ensemble
|
||||
|
||||
```
|
||||
PC de l'administrateur VPS (Debian 13)
|
||||
┌──────────────────────────────┐ HTTPS ┌─────────────────────────────────────────────┐
|
||||
│ GameLauncher.exe │──────────►│ nginx (TLS, limites, fichiers statiques) │
|
||||
│ ├─ Module « Administration »│ │ ├─ /exovivalzombie/… manifest, zip, objects│
|
||||
│ │ clé privée (Windows │ │ ├─ /launcher/… mise à jour launcher │
|
||||
│ │ Hello/TPM ou fichier │ │ ├─ /catalog/… infos des jeux (ph. 3)│
|
||||
│ │ chiffré) │ │ └─ /api/ ──► Launcher API (127.0.0.1:5080) │
|
||||
│ └─ GameLauncher.ApiClient │ │ ASP.NET Core, service systemd durci │
|
||||
└──────────────────────────────┘ │ données : /var/lib/launcher-api (700) │
|
||||
└─────────────────────────────────────────────┘
|
||||
PC d'un joueur : lit uniquement les fichiers statiques (aucun accès à l'API d'administration).
|
||||
```
|
||||
|
||||
## 4. Projets de la solution
|
||||
|
||||
| Projet | Cible | Rôle |
|
||||
|---|---|---|
|
||||
| `src/GameLauncher` | net8.0-windows (WPF) | Application ; hôte des modules ; module Administration (phase 2) |
|
||||
| `src/GameLauncher.Core` | net8.0 | Logique du jeu (inchangée) : manifest, téléchargement, installation… |
|
||||
| `src/GameLauncher.Contracts` | net8.0 | **Nouveau.** Protocole partagé launcher ⇄ serveur : routes, DTO, rôles et permissions, messages signés, contrôle des clés publiques |
|
||||
| `src/GameLauncher.ApiClient` | net8.0 | **Nouveau.** Client HTTP de l'API (connexion par défi signé, requêtes signées), fichier de clé chiffré |
|
||||
| `server/GameLauncher.Server` | net8.0 (ASP.NET Core) | **Nouveau.** Launcher API : modules serveur, authentification, administrateurs, audit, outil en ligne de commande |
|
||||
| `tools/AdminKeyTool` | net8.0 | **Nouveau.** Crée une clé d'administrateur (fichier chiffré) et teste une connexion à l'API |
|
||||
| `tools/ManifestBuilder`, `tools/LauncherPublisher` | net8.0 | Inchangés (le `.bat` de génération est conservé) |
|
||||
| `tests/GameLauncher.Tests` | net8.0 (xUnit) | **Nouveau.** Tests unitaires + tests de bout en bout (serveur démarré en mémoire) |
|
||||
|
||||
Dépendances : `Contracts` ne dépend de rien ; `ApiClient` → `Contracts` ; `Server` → `Contracts`
|
||||
(+ `Core` à partir de la phase 3) ; `GameLauncher` → `Core`, `Contracts`, `ApiClient`.
|
||||
Aucun paquet NuGet n'est nécessaire en dehors du projet de tests (xUnit).
|
||||
|
||||
## 5. Modules
|
||||
|
||||
### 5.1 Côté serveur (phase 1)
|
||||
|
||||
```csharp
|
||||
public interface IApiModule
|
||||
{
|
||||
string Name { get; }
|
||||
void ConfigureServices(IServiceCollection services, LauncherApiOptions options);
|
||||
void MapEndpoints(IEndpointRouteBuilder api); // sous /api/v1
|
||||
}
|
||||
```
|
||||
|
||||
Modules livrés en phase 1 : `System` (infos, santé), `Auth` (défi, connexion, session, enrôlement),
|
||||
`Admins` (gestion des administrateurs), `Audit` (journal). Les phases suivantes ajoutent `Catalog`,
|
||||
`Changelog`, `Releases`, puis les modules « joueurs ». Chaque module déclare ses **permissions**
|
||||
(`AdminPermissions`), protège ses routes avec `.RequirePermission(...)` et ses modifications avec
|
||||
`.RequireSignedRequest()`.
|
||||
|
||||
### 5.2 Côté launcher (phase 2)
|
||||
|
||||
```csharp
|
||||
public interface ILauncherModule
|
||||
{
|
||||
string Id { get; } // "admin", "news", "friends"…
|
||||
void Register(IModuleRegistry registry);
|
||||
}
|
||||
|
||||
public interface IModuleRegistry
|
||||
{
|
||||
void AddResources(Uri resourceDictionary); // modèles WPF du module (DataTemplate)
|
||||
void AddAdminPanel(AdminPanelDescriptor panel); // page du menu d'administration
|
||||
void AddPage(PageDescriptor page); // section de la fenêtre principale (futur)
|
||||
void AddService<T>(T service) where T : class; // services partagés
|
||||
}
|
||||
|
||||
public sealed record AdminPanelDescriptor(
|
||||
string Id, string Title, string IconKey, string RequiredPermission, int Order,
|
||||
Func<IAdminContext, AdminPanelViewModel> CreateViewModel);
|
||||
```
|
||||
|
||||
* Le **menu d'administration** est une fenêtre « coquille » : navigation à gauche (pages fournies
|
||||
par les modules, **filtrées selon les permissions** renvoyées par le serveur), en-tête avec
|
||||
l'administrateur connecté, durée de session restante et bouton « Verrouiller ».
|
||||
* `IAdminContext` donne à chaque page : la session (`AdminSession` : requêtes signées), les jeux
|
||||
déclarés dans `launcher.json`, les boîtes de dialogue, le journal.
|
||||
* Les modules futurs ajoutent leurs pages joueurs (`AddPage`) **et** leurs pages d'administration
|
||||
(`AddAdminPanel`) : ils s'intègrent au menu admin sans le modifier.
|
||||
|
||||
## 6. Authentification par clé publique/privée
|
||||
|
||||
### 6.1 Clés
|
||||
|
||||
* ECDSA **P-256 / SHA-256**, signature au format IEEE P1363 (64 octets) — même famille que la
|
||||
signature des mises à jour du launcher, entièrement fournie par .NET (aucune dépendance).
|
||||
* `keyId` = 16 premiers caractères hexadécimaux du SHA-256 de la clé publique (SPKI).
|
||||
* **Empreinte** affichée aux humains : 5 groupes de 4 caractères (`AB12 CD34 EF56 7890 1A2B`, 80 bits),
|
||||
calculée avec une séparation de domaine : elle ne se déduit pas du `keyId`, visible dans les listes.
|
||||
* Stockage de la clé privée sur le PC de l'administrateur (phase 2) :
|
||||
* **Windows Hello / TPM** si disponible : clé non exportable, liée au PC, déverrouillée par le
|
||||
code PIN Windows (un PC = une clé ; un administrateur peut enregistrer plusieurs PC) ;
|
||||
* sinon **fichier chiffré** (PKCS#8, AES-256, PBKDF2-SHA256 600 000 itérations — comme la clé de
|
||||
signature du launcher) déverrouillé par une phrase de passe de 12 caractères minimum.
|
||||
|
||||
### 6.2 Connexion (défi-réponse)
|
||||
|
||||
```
|
||||
launcher serveur
|
||||
│ POST /api/v1/auth/challenge {keyId} ───► défi SANS ÉTAT : clé + échéance (60 s) + nonce 256 bits,
|
||||
│ scellés par HMAC (secret en mémoire) : rien à stocker
|
||||
│ ◄─── {challengeId, nonce, serverId} (même réponse pour une clé inconnue)
|
||||
│ signe le message de connexion ─────────► POST /api/v1/auth/login
|
||||
│ vérifie : sceau, clé visée, échéance, signature,
|
||||
│ puis clé et compte actifs, défi jamais utilisé (rejeu)
|
||||
│ ◄─── jeton de session + droits jeton 256 bits (seule son empreinte est gardée)
|
||||
```
|
||||
|
||||
Message signé (séparation de domaine, une ligne par champ) :
|
||||
|
||||
```
|
||||
GameLauncher/Admin/Login/v1
|
||||
origin:https://launcher.lanisys.fr
|
||||
server:<serverId>
|
||||
challenge:<challengeId>
|
||||
nonce:<nonce>
|
||||
key:<keyId>
|
||||
```
|
||||
|
||||
`origin` (adresse publique de l'API) et `serverId` (identifiant aléatoire propre au serveur)
|
||||
empêchent de réutiliser une signature auprès d'un autre serveur.
|
||||
|
||||
### 6.3 Sessions
|
||||
|
||||
* En mémoire uniquement (un redémarrage du service déconnecte tout le monde) ; **20 min
|
||||
d'inactivité, 8 h au maximum**, 5 sessions par administrateur.
|
||||
* À chaque requête : session valide **et** administrateur actif **et** clé active (une révocation
|
||||
prend effet immédiatement, y compris depuis l'outil en ligne de commande).
|
||||
|
||||
### 6.4 Requêtes signées
|
||||
|
||||
Toute requête qui **modifie** quelque chose porte, en plus du jeton, une signature de la clé de
|
||||
l'administrateur :
|
||||
|
||||
```
|
||||
GameLauncher/Admin/Request/v1
|
||||
origin:<origine>
|
||||
server:<serverId>
|
||||
session:<identifiant de session>
|
||||
method:POST
|
||||
path:/api/v1/admins/invitations
|
||||
body-sha256:<SHA-256 du corps>
|
||||
timestamp:<secondes Unix>
|
||||
nonce:<aléatoire>
|
||||
```
|
||||
|
||||
En-têtes `X-GL-Timestamp`, `X-GL-Nonce`, `X-GL-Signature`. Le serveur refuse une horloge décalée
|
||||
de plus de 2 minutes et tout nonce déjà vu (rejeu). Bénéfices : un jeton volé ne permet aucune
|
||||
modification ; le journal d'audit sait quelle clé a signé chaque modification.
|
||||
|
||||
### 6.5 Défenses complémentaires
|
||||
|
||||
| Menace | Défense |
|
||||
|---|---|
|
||||
| Force brute / saturation | Limites par IP (IPv6 : par /64 ; nginx + ASP.NET Core) ; défis sans état (aucune réserve à saturer) ; sessions en nombre borné |
|
||||
| Énumération des clés | Défi identique pour une clé connue ou inconnue ; erreurs génériques |
|
||||
| Rejeu | Défi à usage unique (60 s) ; nonce + horodatage des requêtes signées |
|
||||
| Afflux de tentatives dans l'audit | Échecs sans signature valide détaillés 10 fois par minute au plus, puis résumés ; échecs AVEC signature valide (clé révoquée ou en attente, rejeu) toujours détaillés |
|
||||
| Vol du jeton | Inutile pour modifier (requêtes signées) ; courte durée ; lié à la clé |
|
||||
| Fuite des données du serveur | Uniquement des clés publiques et des empreintes de codes d'invitation |
|
||||
| Compromission du compte `deploy` | Données de l'API hors du chroot SFTP (`/var/lib/launcher-api`) |
|
||||
| En-têtes falsifiés | `X-Forwarded-*` acceptés uniquement depuis 127.0.0.1 ; HTTPS exigé derrière nginx |
|
||||
| Usurpation par un autre administrateur | Un nouvel appareil n'est approuvé que par le titulaire du compte ; droits de l'auteur revérifiés au moment d'écrire |
|
||||
| Erreur de manipulation | Impossible de se révoquer soi-même, de changer son propre rôle, ou de supprimer le dernier propriétaire |
|
||||
| Perte de toutes les clés | Récupération par l'outil en ligne de commande, sur le VPS (accès SSH administrateur) |
|
||||
|
||||
## 7. Rôles et permissions
|
||||
|
||||
| Permission | Propriétaire | Administrateur | Éditeur | Utilisée par |
|
||||
|---|:-:|:-:|:-:|---|
|
||||
| `admins.manage` | ✔ | | | Gestion des administrateurs (phase 1 serveur, phase 6 interface) |
|
||||
| `audit.read` | ✔ | ✔ | | Journal d'audit |
|
||||
| `games.edit` | ✔ | ✔ | ✔ | Informations des jeux (phase 3) |
|
||||
| `changelog.edit` | ✔ | ✔ | ✔ | Notes de version (phase 4) |
|
||||
| `releases.publish` | ✔ | ✔ | | Publication d'une version (phase 5) |
|
||||
|
||||
Les permissions sont des chaînes « module.action » : un module futur déclare les siennes
|
||||
(`news.edit`, `community.moderate`…) et les rôles qui les reçoivent.
|
||||
|
||||
## 8. Ajouter un administrateur
|
||||
|
||||
1. Un **propriétaire** crée une invitation (nom, rôle) : le serveur renvoie **une seule fois** un
|
||||
code à usage unique (160 bits, `ABCD-EFGH-…`, valable 24 h ; seule son empreinte est stockée).
|
||||
2. Il transmet ce code au futur administrateur.
|
||||
3. Le futur administrateur ouvre le menu d'administration › « J'ai une invitation » : le launcher
|
||||
crée sa clé, l'envoie avec le code et une **preuve de possession** (signature), et affiche son
|
||||
**empreinte**.
|
||||
4. Il communique son empreinte au propriétaire **par un autre moyen** (téléphone, message).
|
||||
5. Le propriétaire approuve en **saisissant cette empreinte** (le serveur ne l'affiche pas pour les
|
||||
clés en attente : l'approbation prouve la vérification). Clé et compte deviennent actifs.
|
||||
|
||||
**Ajouter un PC** à un compte existant (invitation « nouvel appareil ») : l'administrateur crée
|
||||
lui-même l'invitation, enrôle son nouveau PC, puis **approuve depuis un de ses PC déjà actifs**.
|
||||
Personne d'autre — pas même un propriétaire — ne peut ajouter une clé à un compte déjà actif
|
||||
(sinon un propriétaire pourrait révoquer les clés d'un autre puis y ajouter la sienne et agir sous
|
||||
son nom). Tous ses PC perdus : restauration sur le VPS (`add-key`, accès SSH), ou nouveau compte.
|
||||
Le **premier propriétaire** est créé sur le VPS avec l'outil en ligne de commande (preuve
|
||||
d'identité = accès SSH administrateur) :
|
||||
|
||||
```
|
||||
sudo -u deploy /opt/launcher-api/GameLauncher.Server add-owner --name "Nicolas" --public-key /tmp/owner.pub
|
||||
```
|
||||
|
||||
## 9. Informations fixes et informations modifiables des jeux (phase 3)
|
||||
|
||||
Les jeux sont **toujours déclarés dans `launcher.json`** : le serveur ne peut ni ajouter ni retirer
|
||||
un jeu, seulement modifier l'affichage des jeux déclarés. Les champs actuels de `launcher.json` se
|
||||
répartissent ainsi :
|
||||
|
||||
| Champ | Reste dans l'exe | Modifiable (serveur) | Raison |
|
||||
|---|:-:|:-:|---|
|
||||
| `launcherId`, `dataFolder` | ✔ | | Identité du produit, dossiers et registre |
|
||||
| `publisher`, `gameId` | ✔ | | Identité, registre, raccourcis |
|
||||
| `manifestUrl`, `launcherUpdateUrl` | ✔ | | Sources de confiance |
|
||||
| `apiUrl`, `catalogUrl` (nouveaux) | ✔ | | Sources de confiance (jamais remplaçables par un `launcher.json` externe) |
|
||||
| `gameDataFolder` | ✔ | | Dossiers supprimés à la désinstallation : jamais piloté à distance |
|
||||
| `defaultInstallDirectory`, `gameExecutable`, `gameArguments` | ✔ | | Disque et exécution sur le PC du joueur |
|
||||
| `gameName`, `tagline` | valeur par défaut | ✔ | Affichage |
|
||||
| `description` (nouveau, texte long), `links` (Discord, YouTube… https) | | ✔ | Affichage |
|
||||
| `websiteUrl` | valeur par défaut | ✔ | https uniquement |
|
||||
| `theme` | valeur par défaut | ✔ | Uniquement un thème **intégré** (pas de XAML distant) |
|
||||
| `icon`, `background` | valeur par défaut | ✔ | Images https du même serveur, taille et dimensions bornées, mises en cache |
|
||||
| `updateCheckIntervalMinutes` | valeur par défaut | ✔ | Borné (0 ou 5 à 1440) |
|
||||
| `launcherName`, `defaultGame`, ordre et visibilité des jeux | valeur par défaut | ✔ | Affichage |
|
||||
|
||||
Fonctionnement côté joueur : `catalog.json` (statique, sur le serveur) est lu au démarrage,
|
||||
validé et borné comme les notes de version, puis **mis en cache** (`%AppData%\…\GameLauncher`).
|
||||
Sans réseau : dernier cache, sinon valeurs de `launcher.json`. Le serveur écrit `catalog.json`
|
||||
seulement via l'API (permission `games.edit`, requête signée).
|
||||
|
||||
## 10. Notes de version (phase 4)
|
||||
|
||||
* Éditeur graphique dans le menu admin : arborescence version › sections › éléments, édition des
|
||||
champs du format enrichi, **aperçu en direct avec les mêmes modèles que le launcher**
|
||||
(`Themes/Changelog.xaml`) et les mêmes bornes (`ChangelogLimits`).
|
||||
* On édite **les versions existantes** uniquement ; la création d'une version reste au `.bat`.
|
||||
* Enregistrement : le launcher envoie le `manifest.json` modifié avec l'empreinte de la version
|
||||
qu'il a lue (concurrence optimiste) ; le serveur vérifie que **seules les notes ont changé**
|
||||
(fichiers, archive, version, exécutable identiques), valide, sauvegarde l'ancien manifest puis
|
||||
remplace atomiquement.
|
||||
* **Cohérence avec ManifestBuilder** : aujourd'hui il reprend l'historique des notes depuis le
|
||||
`manifest.json` *local*, ce qui écraserait les corrections faites en ligne. ManifestBuilder
|
||||
recevra une option `--remote-manifest <url>` (ajoutée au `.bat`) : l'historique est repris du
|
||||
serveur, seule la nouvelle version vient de `notes.txt`.
|
||||
|
||||
## 11. Publication d'une version (phase 5)
|
||||
|
||||
Depuis le menu admin › « Publier une version » : choix du dossier produit par le `.bat`, contrôle
|
||||
local (manifest valide, archive et objets présents avec les bonnes empreintes), puis envoi :
|
||||
|
||||
1. `objects/` : seuls les objets **absents du serveur** (le serveur répond à une liste d'empreintes) ;
|
||||
2. l'archive ZIP, **par morceaux** (8 Mo, reprise possible, chaque morceau et le fichier final
|
||||
vérifiés par SHA-256) ;
|
||||
3. `manifest.json` **en dernier**, remplacé atomiquement (même règle qu'avec WinSCP).
|
||||
|
||||
Le serveur vérifie que chaque fichier référencé existe avec la bonne taille et la bonne empreinte
|
||||
avant d'accepter le manifest. L'historique des notes du serveur est conservé.
|
||||
|
||||
## 12. Signature de bout en bout pour les joueurs (phase 7, recommandée)
|
||||
|
||||
Aujourd'hui `manifest.json` n'est pas signé : une prise de contrôle du VPS permettrait de
|
||||
distribuer un faux jeu. La phase 7 ajoute :
|
||||
|
||||
* `keyring.json` : liste des clés publiques des administrateurs, **signée par la clé racine**
|
||||
(votre clé de signature du launcher, déjà intégrée à l'exe) ;
|
||||
* une **signature détachée** (`manifest.json.sig`, `catalog.json.sig`) produite par la clé de
|
||||
l'administrateur au moment de la publication ou de l'édition — les anciens launchers ignorent
|
||||
ces fichiers ;
|
||||
* côté joueur, vérification puis refus de tout contenu non signé par une clé du trousseau.
|
||||
|
||||
Le format des requêtes signées de la phase 1 est déjà compatible avec ce modèle.
|
||||
|
||||
## 13. Phases
|
||||
|
||||
| Phase | Contenu | Statut |
|
||||
|---|---|---|
|
||||
| **0** | Architecture (ce document) | ✅ |
|
||||
| **1** | Contrats partagés ; Launcher API (modules, authentification par clé, sessions, requêtes signées, invitations/enrôlement/approbation/révocation, audit, limites, CLI) ; client API ; outil `AdminKeyTool` ; tests ; fichiers de déploiement (systemd, nginx) | ✅ |
|
||||
| **2** | Launcher : hôte de modules, entrée cachée (Ctrl+Maj+Alt+A ou 5 clics sur le logo), fenêtre d'administration (navigation, session, verrouillage auto), stockage de clé (Windows Hello/TPM, fichier chiffré), connexion, « J'ai une invitation », tableau de bord | à faire |
|
||||
| **3** | Séparation fixe/modifiable : `catalog.json`, lecture + cache côté joueur, module serveur `Catalog`, page admin « Jeux » | à faire |
|
||||
| **4** | Éditeur de notes de version + module serveur `Changelog` + option `--remote-manifest` de ManifestBuilder | à faire |
|
||||
| **5** | Publication d'une version depuis le menu admin (envoi par morceaux, reprise, vérifications) | à faire |
|
||||
| **6** | Pages admin « Administrateurs » (invitations, approbation par empreinte, rôles, révocation, appareils) et « Journal d'audit » | à faire |
|
||||
| **7** | Signature de bout en bout (trousseau signé par la clé racine, signatures détachées, vérification côté joueur) | recommandée |
|
||||
|
||||
Chaque phase compile, est testée et laisse le launcher utilisable : tant que la phase 2 n'est pas
|
||||
livrée, rien ne change pour les joueurs.
|
||||
|
||||
## 14. Choix écartés
|
||||
|
||||
| Option | Pourquoi elle n'a pas été retenue |
|
||||
|---|---|
|
||||
| Identifiant + mot de passe + OTP | Mots de passe hameçonnables ; secrets OTP stockés sur le serveur |
|
||||
| Envoi SFTP direct depuis le launcher | Pas de droits par rôle, pas d'audit, pas de validation serveur, clé SFTP = accès total aux fichiers |
|
||||
| Secret partagé dans l'exe | Extractible par n'importe quel joueur |
|
||||
| Base de données (SQL) | Inutile à cette échelle : fichiers JSON écrits atomiquement, verrou inter-processus, sauvegarde |
|
||||
| Thème XAML distant | Un XAML téléchargé peut référencer du code : seuls les thèmes intégrés sont sélectionnables |
|
||||
@@ -0,0 +1,257 @@
|
||||
using System.Security.Cryptography;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Storage;
|
||||
|
||||
namespace GameLauncher.Server.Cli;
|
||||
|
||||
/// <summary>
|
||||
/// Outil en ligne de commande, à lancer SUR LE VPS sous le compte du service (accès SSH
|
||||
/// administrateur = preuve d'identité). Sert à créer le premier propriétaire et à reprendre la main
|
||||
/// si toutes les clés sont perdues. Le service n'a pas besoin d'être arrêté : il relit admins.json.
|
||||
///
|
||||
/// sudo -u deploy /opt/launcher-api/GameLauncher.Server add-owner --name "Nicolas" --public-key /tmp/owner.pub
|
||||
/// </summary>
|
||||
public static class AdminCli
|
||||
{
|
||||
private static readonly string[] Commands = { "add-owner", "add-key", "list", "revoke-admin", "revoke-key", "server-id", "help" };
|
||||
|
||||
public static bool IsCommand(string[] args) =>
|
||||
args.Length > 0 && Commands.Contains(args[0], StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static int Run(string[] args, TextWriter? output = null, string? dataDirectoryOverride = null)
|
||||
{
|
||||
var o = output ?? Console.Out;
|
||||
try
|
||||
{
|
||||
var command = args[0].ToLowerInvariant();
|
||||
if (command == "help") return Help(o);
|
||||
var options = ReadOptions(args);
|
||||
|
||||
// Lancé par erreur avec « sudo » (root) au lieu de « sudo -u deploy » : les fichiers recréés
|
||||
// appartiendraient à root et le service ne pourrait plus les lire.
|
||||
if (dataDirectoryOverride is null && !OperatingSystem.IsWindows() && Environment.UserName == "root")
|
||||
throw new InvalidOperationException(
|
||||
"ne lancez pas cet outil en root : sudo -u deploy /opt/launcher-api/GameLauncher.Server " + command + " …");
|
||||
|
||||
var dataDirectory = dataDirectoryOverride ?? ResolveDataDirectory(options);
|
||||
if (command != "add-owner" && command != "server-id" &&
|
||||
!File.Exists(Path.Combine(dataDirectory, AdminStore.FileName)))
|
||||
throw new InvalidOperationException(
|
||||
$"aucun fichier {AdminStore.FileName} dans {dataDirectory} : mauvais dossier de données ? (option --data-dir)");
|
||||
if (output is null) Console.Error.WriteLine($"Données : {dataDirectory}");
|
||||
|
||||
var store = new AdminStore(dataDirectory);
|
||||
var audit = new AuditLog(dataDirectory, TimeProvider.System);
|
||||
var actor = $"cli ({Environment.UserName})";
|
||||
|
||||
switch (command)
|
||||
{
|
||||
case "add-owner": return AddOwner(store, audit, actor, options, o);
|
||||
case "add-key": return AddKey(store, audit, actor, options, o);
|
||||
case "list": return List(store, o);
|
||||
case "revoke-admin": return RevokeAdmin(store, audit, actor, options, o);
|
||||
case "revoke-key": return RevokeKey(store, audit, actor, options, o);
|
||||
case "server-id":
|
||||
o.WriteLine(ServerIdentity.LoadOrCreate(dataDirectory));
|
||||
return 0;
|
||||
default: return Help(o);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or InvalidDataException or IOException
|
||||
or UnauthorizedAccessException or CryptographicException or ApiRejection or StoreBusyException)
|
||||
{
|
||||
(output ?? Console.Error).WriteLine($"Erreur : {ex.Message}");
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
private static int AddOwner(AdminStore store, AuditLog audit, string actor, Dictionary<string, string> options, TextWriter o)
|
||||
{
|
||||
var name = ApiText.CleanLine(Get(options, "name"), ApiText.MaxDisplayNameLength)
|
||||
?? throw new ArgumentException("--name obligatoire (64 caractères au plus).");
|
||||
var keyArg = Get(options, "public-key") ?? throw new ArgumentException("--public-key obligatoire (fichier .pub ou Base64).");
|
||||
var keyText = File.Exists(keyArg) ? File.ReadAllText(keyArg) : keyArg;
|
||||
var spki = AdminKeys.ParsePublicKey(keyText);
|
||||
var keyId = AdminKeys.ComputeKeyId(spki);
|
||||
var label = ApiText.CleanLine(Get(options, "label"), ApiText.MaxKeyLabelLength) ?? "Clé ajoutée sur le serveur";
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var id = store.Update(db =>
|
||||
{
|
||||
if (db.FindKey(keyId) is not null) throw new InvalidOperationException($"La clé {keyId} est déjà enregistrée.");
|
||||
var admin = new AdminRecord
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
DisplayName = name,
|
||||
Role = AdminRoles.Owner,
|
||||
Status = AdminStatus.Active,
|
||||
CreatedAt = now,
|
||||
CreatedBy = actor,
|
||||
ApprovedAt = now,
|
||||
ApprovedBy = actor,
|
||||
Keys =
|
||||
{
|
||||
new KeyRecord
|
||||
{
|
||||
KeyId = keyId, PublicKey = Convert.ToBase64String(spki), Label = label,
|
||||
Status = AdminStatus.Active, AddedAt = now, ApprovedAt = now, ApprovedBy = actor
|
||||
}
|
||||
}
|
||||
};
|
||||
db.Admins.Add(admin);
|
||||
return admin.Id;
|
||||
});
|
||||
audit.Write("cli.add_owner", AuditResults.Ok, actorName: actor, keyId: keyId, target: name);
|
||||
o.WriteLine($"Propriétaire créé : {name} ({id:D})");
|
||||
o.WriteLine($" Clé : {keyId}");
|
||||
o.WriteLine($" Empreinte: {AdminKeys.ComputeFingerprint(spki)} ← doit être celle affichée à la création de la clé");
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>Restaure l'accès d'un administrateur qui a perdu tous ses appareils (clé active immédiatement).</summary>
|
||||
private static int AddKey(AdminStore store, AuditLog audit, string actor, Dictionary<string, string> options, TextWriter o)
|
||||
{
|
||||
if (!Guid.TryParse(Get(options, "admin"), out var adminId)) throw new ArgumentException("--admin <identifiant> obligatoire (voir « list »).");
|
||||
var keyArg = Get(options, "public-key") ?? throw new ArgumentException("--public-key obligatoire (fichier .pub ou Base64).");
|
||||
var spki = AdminKeys.ParsePublicKey(File.Exists(keyArg) ? File.ReadAllText(keyArg) : keyArg);
|
||||
var keyId = AdminKeys.ComputeKeyId(spki);
|
||||
var label = ApiText.CleanLine(Get(options, "label"), ApiText.MaxKeyLabelLength) ?? "Clé ajoutée sur le serveur";
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var name = store.Update(db =>
|
||||
{
|
||||
var admin = db.FindAdmin(adminId) ?? throw new InvalidOperationException("Administrateur introuvable.");
|
||||
if (admin.Status == AdminStatus.Revoked) throw new InvalidOperationException("Cet administrateur est révoqué : créez un nouveau compte.");
|
||||
if (db.FindKey(keyId) is not null) throw new InvalidOperationException($"La clé {keyId} est déjà enregistrée.");
|
||||
admin.Keys.Add(new KeyRecord
|
||||
{
|
||||
KeyId = keyId, PublicKey = Convert.ToBase64String(spki), Label = label,
|
||||
Status = AdminStatus.Active, AddedAt = now, ApprovedAt = now, ApprovedBy = actor
|
||||
});
|
||||
if (admin.Status == AdminStatus.Pending)
|
||||
{
|
||||
admin.Status = AdminStatus.Active;
|
||||
admin.ApprovedAt = now;
|
||||
admin.ApprovedBy = actor;
|
||||
}
|
||||
return admin.DisplayName;
|
||||
});
|
||||
audit.Write("cli.add_key", AuditResults.Ok, actorName: actor, keyId: keyId, target: name);
|
||||
o.WriteLine($"Clé ajoutée à {name} : {keyId}");
|
||||
o.WriteLine($" Empreinte: {AdminKeys.ComputeFingerprint(spki)} ← doit être celle affichée à la création de la clé");
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static int List(AdminStore store, TextWriter o)
|
||||
{
|
||||
var db = store.Read();
|
||||
if (db.Admins.Count == 0) o.WriteLine("Aucun administrateur.");
|
||||
foreach (var admin in db.Admins.OrderBy(a => a.CreatedAt))
|
||||
{
|
||||
o.WriteLine($"{admin.DisplayName} [{AdminRoles.DisplayName(admin.Role)}, {admin.Status}] {admin.Id:D}");
|
||||
foreach (var key in admin.Keys)
|
||||
o.WriteLine($" clé {key.KeyId} {AdminKeys.ComputeFingerprint(key.PublicKeyBytes())} {key.Status,-8} {key.Label}" +
|
||||
(key.LastUsedAt is { } used ? $" (dernière connexion {used:yyyy-MM-dd HH:mm} UTC)" : ""));
|
||||
}
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
foreach (var invitation in db.Invitations.Where(i => i.ExpiresAt > now))
|
||||
o.WriteLine($"Invitation {invitation.Id:D} : {invitation.DisplayName} ({invitation.Role}), expire {invitation.ExpiresAt:yyyy-MM-dd HH:mm} UTC");
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static int RevokeAdmin(AdminStore store, AuditLog audit, string actor, Dictionary<string, string> options, TextWriter o)
|
||||
{
|
||||
if (!Guid.TryParse(Get(options, "id"), out var id)) throw new ArgumentException("--id <identifiant> obligatoire (voir « list »).");
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var name = store.Update(db =>
|
||||
{
|
||||
var admin = db.FindAdmin(id) ?? throw new InvalidOperationException("Administrateur introuvable.");
|
||||
admin.Status = AdminStatus.Revoked;
|
||||
admin.RevokedAt = now;
|
||||
admin.RevokedBy = actor;
|
||||
foreach (var key in admin.Keys.Where(k => k.Status != AdminStatus.Revoked))
|
||||
{
|
||||
key.Status = AdminStatus.Revoked;
|
||||
key.RevokedAt = now;
|
||||
key.RevokedBy = actor;
|
||||
}
|
||||
db.Invitations.RemoveAll(i => i.ForAdminId == id);
|
||||
return admin.DisplayName;
|
||||
});
|
||||
audit.Write("cli.revoke_admin", AuditResults.Ok, actorName: actor, target: name);
|
||||
o.WriteLine($"Administrateur révoqué : {name}. Ses sessions sont fermées à sa prochaine requête.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static int RevokeKey(AdminStore store, AuditLog audit, string actor, Dictionary<string, string> options, TextWriter o)
|
||||
{
|
||||
var keyId = Get(options, "key")?.ToLowerInvariant();
|
||||
if (!AdminKeys.IsValidKeyId(keyId)) throw new ArgumentException("--key <identifiant de clé> obligatoire (voir « list »).");
|
||||
var name = store.Update(db =>
|
||||
{
|
||||
var (admin, key) = db.FindKey(keyId!) ?? throw new InvalidOperationException("Clé introuvable.");
|
||||
key.Status = AdminStatus.Revoked;
|
||||
key.RevokedAt = DateTimeOffset.UtcNow;
|
||||
key.RevokedBy = actor;
|
||||
return admin.DisplayName;
|
||||
});
|
||||
audit.Write("cli.revoke_key", AuditResults.Ok, actorName: actor, keyId: keyId, target: name);
|
||||
o.WriteLine($"Clé {keyId} de {name} révoquée.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static int Help(TextWriter o)
|
||||
{
|
||||
o.WriteLine("""
|
||||
Launcher API — outil d'administration (à lancer sur le VPS, sous le compte du service)
|
||||
|
||||
add-owner --name <nom> --public-key <fichier.pub | Base64> [--label <poste>]
|
||||
Crée un propriétaire actif (premier administrateur, ou reprise de la main).
|
||||
add-key --admin <identifiant> --public-key <fichier.pub | Base64> [--label <poste>]
|
||||
Restaure l'accès d'un administrateur qui a perdu tous ses appareils.
|
||||
list Administrateurs, clés (empreintes) et invitations en cours.
|
||||
revoke-admin --id <identifiant> Révoque un administrateur et toutes ses clés.
|
||||
revoke-key --key <identifiant> Révoque une clé (poste perdu ou volé).
|
||||
server-id Affiche l'identifiant de ce serveur.
|
||||
|
||||
Option commune : --data-dir <dossier> (sinon LauncherApi:DataDirectory d'appsettings.json).
|
||||
""");
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ReadOptions(string[] args)
|
||||
{
|
||||
var options = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var i = 1; i < args.Length; i++)
|
||||
{
|
||||
if (!args[i].StartsWith("--", StringComparison.Ordinal)) throw new ArgumentException($"Argument inattendu : {args[i]}");
|
||||
if (i + 1 >= args.Length) throw new ArgumentException($"Valeur manquante pour {args[i]}");
|
||||
options[args[i][2..]] = args[++i];
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Même configuration que le service : appsettings.json, puis appsettings.{environnement}.json
|
||||
/// (Production par défaut, comme le service systemd), puis variables d'environnement.
|
||||
/// </summary>
|
||||
private static string ResolveDataDirectory(Dictionary<string, string> options)
|
||||
{
|
||||
if (Get(options, "data-dir") is { Length: > 0 } explicitDirectory) return Path.GetFullPath(explicitDirectory);
|
||||
var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")
|
||||
?? Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") ?? "Production";
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.SetBasePath(AppContext.BaseDirectory)
|
||||
.AddJsonFile("appsettings.json", optional: true)
|
||||
.AddJsonFile($"appsettings.{environment}.json", optional: true)
|
||||
.AddEnvironmentVariables()
|
||||
.Build();
|
||||
var directory = configuration[$"{LauncherApiOptions.Section}:DataDirectory"] ?? new LauncherApiOptions().DataDirectory;
|
||||
return Path.GetFullPath(Path.IsPathRooted(directory) ? directory : Path.Combine(AppContext.BaseDirectory, directory));
|
||||
}
|
||||
|
||||
private static string? Get(Dictionary<string, string> options, string name) =>
|
||||
options.TryGetValue(name, out var value) ? value : null;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<!--
|
||||
Launcher API : serveur d'administration (et, plus tard, des fonctionnalités « joueurs »).
|
||||
Déployé sur le VPS derrière nginx (https://<hote>/api/), service systemd durci.
|
||||
Voir docs\ADMIN-ARCHITECTURE.md et server\deploy\LISEZMOI.md.
|
||||
|
||||
Publication pour le VPS (autonome linux-x64 : aucun runtime .NET à installer sur le serveur) :
|
||||
tools\publish-server.bat
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RootNamespace>GameLauncher.Server</RootNamespace>
|
||||
<AssemblyName>GameLauncher.Server</AssemblyName>
|
||||
<Platforms>AnyCPU;x64</Platforms>
|
||||
<Version>1.0.0</Version>
|
||||
<Product>Launcher API</Product>
|
||||
<!-- Pas de dépendance à libicu sur le VPS ; les textes restent en UTF-8. -->
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<!-- Petit VPS : ramasse-miettes « poste de travail », moins gourmand en mémoire. -->
|
||||
<ServerGarbageCollection>false</ServerGarbageCollection>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\GameLauncher.Contracts\GameLauncher.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="GameLauncher.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,23 @@
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
|
||||
namespace GameLauncher.Server.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Refus d'une requête (levé n'importe où, y compris dans <see cref="Storage.AdminStore.Update{T}"/> :
|
||||
/// rien n'est alors écrit). Transformé en réponse <see cref="ApiError"/> par ServerApp.
|
||||
/// </summary>
|
||||
public sealed class ApiRejection : Exception
|
||||
{
|
||||
public ApiRejection(int status, string code, string message) : base(message)
|
||||
{
|
||||
Status = status;
|
||||
Code = code;
|
||||
}
|
||||
|
||||
public int Status { get; }
|
||||
public string Code { get; }
|
||||
|
||||
public static ApiRejection BadRequest(string message, string code = ApiErrorCodes.InvalidRequest) => new(400, code, message);
|
||||
public static ApiRejection NotFound(string message = "Élément introuvable.") => new(404, ApiErrorCodes.NotFound, message);
|
||||
public static ApiRejection Conflict(string message, string code = ApiErrorCodes.Conflict) => new(409, code, message);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
|
||||
namespace GameLauncher.Server.Hosting;
|
||||
|
||||
/// <summary>Réponses d'erreur uniformes (<see cref="ApiError"/>), sans détail interne.</summary>
|
||||
public static class ApiResults
|
||||
{
|
||||
public static IResult Error(int status, string code, string message) =>
|
||||
Results.Json(new ApiError { Status = status, Code = code, Message = message }, ApiJson.Options, statusCode: status);
|
||||
|
||||
public static IResult BadRequest(string message, string code = ApiErrorCodes.InvalidRequest) => Error(400, code, message);
|
||||
public static IResult Unauthorized(string message = "Authentification requise.", string code = ApiErrorCodes.Unauthorized) =>
|
||||
Error(401, code, message);
|
||||
public static IResult Forbidden(string message = "Action non autorisée.") => Error(403, ApiErrorCodes.Forbidden, message);
|
||||
public static IResult NotFound(string message = "Élément introuvable.") => Error(404, ApiErrorCodes.NotFound, message);
|
||||
public static IResult Conflict(string message, string code = ApiErrorCodes.Conflict) => Error(409, code, message);
|
||||
public static IResult Busy() => Error(503, ApiErrorCodes.Busy, "Serveur momentanément occupé. Réessayez dans un instant.");
|
||||
|
||||
/// <summary>Écrit une erreur directement dans la réponse (middlewares, authentification).</summary>
|
||||
public static Task WriteAsync(HttpContext context, int status, string code, string message)
|
||||
{
|
||||
context.Response.StatusCode = status;
|
||||
return context.Response.WriteAsJsonAsync(new ApiError { Status = status, Code = code, Message = message }, ApiJson.Options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace GameLauncher.Server.Hosting;
|
||||
|
||||
public static class HttpContextExtensions
|
||||
{
|
||||
/// <summary>Adresse IP du client (celle transmise par nginx, seul proxy de confiance).</summary>
|
||||
public static string? ClientIp(this HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
|
||||
|
||||
/// <summary>
|
||||
/// Partition des limites de débit : adresse IPv4, ou préfixe /64 en IPv6 (un abonné dispose
|
||||
/// généralement de tout un /64 : sans regroupement, chaque adresse aurait sa propre limite).
|
||||
/// </summary>
|
||||
public static string RateLimitPartition(this HttpContext context)
|
||||
{
|
||||
var ip = context.Connection.RemoteIpAddress;
|
||||
if (ip is null) return "inconnue";
|
||||
if (ip.IsIPv4MappedToIPv6) ip = ip.MapToIPv4();
|
||||
if (ip.AddressFamily != AddressFamily.InterNetworkV6) return ip.ToString();
|
||||
var bytes = ip.GetAddressBytes();
|
||||
Array.Clear(bytes, 8, 8);
|
||||
return new IPAddress(bytes) + "/64";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace GameLauncher.Server.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Module du serveur : enregistre ses services et ses routes (sous « /api »). Les modules futurs
|
||||
/// (catalogue, notes de version, publication, puis fonctionnalités « joueurs ») s'ajoutent à
|
||||
/// <see cref="ApiModules.All"/> sans toucher au reste.
|
||||
///
|
||||
/// Règles : routes de lecture protégées par RequirePermission(…) (ou RequireAdmin()) ; routes qui
|
||||
/// modifient protégées en plus par RequireSignedRequest() ; chaque modification écrite dans l'audit.
|
||||
/// </summary>
|
||||
public interface IApiModule
|
||||
{
|
||||
string Name { get; }
|
||||
|
||||
void ConfigureServices(IServiceCollection services, LauncherApiOptions options);
|
||||
|
||||
void MapEndpoints(IEndpointRouteBuilder api);
|
||||
}
|
||||
|
||||
public static class ApiModules
|
||||
{
|
||||
/// <summary>Modules actifs, dans l'ordre d'enregistrement.</summary>
|
||||
public static IReadOnlyList<IApiModule> All { get; } = new IApiModule[]
|
||||
{
|
||||
new Modules.System.SystemModule(),
|
||||
new Modules.Auth.AuthModule(),
|
||||
new Modules.Admins.AdminsModule(),
|
||||
new Modules.Audit.AuditModule()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Noms des limites de débit (voir ServerApp).</summary>
|
||||
public static class RateLimitPolicies
|
||||
{
|
||||
public const string Auth = "auth";
|
||||
public const string Enrollment = "enrollment";
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using System.Net;
|
||||
|
||||
namespace GameLauncher.Server.Hosting;
|
||||
|
||||
/// <summary>Réglages de la Launcher API (section « LauncherApi » d'appsettings.json ou variables d'environnement).</summary>
|
||||
public sealed class LauncherApiOptions
|
||||
{
|
||||
public const string Section = "LauncherApi";
|
||||
|
||||
/// <summary>
|
||||
/// Dossier des données privées (administrateurs, audit, identifiant du serveur). Jamais dans le
|
||||
/// dossier servi par nginx. Relatif : par rapport au dossier de l'application.
|
||||
/// </summary>
|
||||
public string DataDirectory { get; set; } = "/var/lib/launcher-api";
|
||||
|
||||
/// <summary>
|
||||
/// Adresse publique de l'API, sans chemin (ex. « https://launcher.lanisys.fr »). Incluse dans les
|
||||
/// messages signés : une signature produite pour un autre serveur est refusée.
|
||||
/// </summary>
|
||||
public string PublicOrigin { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Exige que les requêtes arrivent en HTTPS par le proxy (en-tête X-Forwarded-Proto de nginx).
|
||||
/// Seule la route de santé reste accessible en HTTP, et seulement depuis la machine elle-même.
|
||||
/// </summary>
|
||||
public bool RequireHttps { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Proxys autorisés à transmettre l'adresse et le protocole du client (X-Forwarded-For/Proto).
|
||||
/// Vide : nginx sur la même machine (127.0.0.1 et ::1). Pas de valeur par défaut dans le tableau :
|
||||
/// la configuration .NET AJOUTE les valeurs lues à celles d'un tableau déjà rempli, ce qui
|
||||
/// laisserait 127.0.0.1 de confiance même si la configuration en désigne d'autres.
|
||||
/// </summary>
|
||||
public string[] TrustedProxies { get; set; } = Array.Empty<string>();
|
||||
|
||||
private static readonly string[] DefaultTrustedProxies = { "127.0.0.1", "::1" };
|
||||
|
||||
public SessionSettings Sessions { get; set; } = new();
|
||||
|
||||
public LimitSettings Limits { get; set; } = new();
|
||||
|
||||
/// <summary>Durée de validité d'un défi de connexion.</summary>
|
||||
public int ChallengeLifetimeSeconds { get; set; } = 60;
|
||||
|
||||
/// <summary>Durée de validité d'un code d'invitation.</summary>
|
||||
public int InvitationLifetimeHours { get; set; } = 24;
|
||||
|
||||
public sealed class SessionSettings
|
||||
{
|
||||
public int IdleMinutes { get; set; } = 20;
|
||||
public int MaxHours { get; set; } = 8;
|
||||
public int MaxPerAdmin { get; set; } = 5;
|
||||
public int MaxTotal { get; set; } = 500;
|
||||
}
|
||||
|
||||
public sealed class LimitSettings
|
||||
{
|
||||
/// <summary>Requêtes par minute et par adresse IP, toutes routes confondues.</summary>
|
||||
public int RequestsPerMinute { get; set; } = 300;
|
||||
|
||||
/// <summary>Défis et connexions par minute et par adresse IP.</summary>
|
||||
public int AuthPerMinute { get; set; } = 20;
|
||||
|
||||
/// <summary>Enrôlements (codes d'invitation) par heure et par adresse IP.</summary>
|
||||
public int EnrollmentsPerHour { get; set; } = 10;
|
||||
|
||||
/// <summary>Invitations actives simultanément.</summary>
|
||||
public int MaxActiveInvitations { get; set; } = 20;
|
||||
|
||||
/// <summary>Taille maximale d'un corps de requête (octets).</summary>
|
||||
public long MaxRequestBodyBytes { get; set; } = 64 * 1024;
|
||||
}
|
||||
|
||||
/// <summary>Origine normalisée (« https://hote[:port] »), calculée par <see cref="Validate"/>.</summary>
|
||||
public string NormalizedOrigin { get; private set; } = "";
|
||||
|
||||
/// <summary>Dossier des données en chemin absolu, calculé par <see cref="Validate"/>.</summary>
|
||||
public string DataDirectoryFullPath { get; private set; } = "";
|
||||
|
||||
public IReadOnlyList<IPAddress> TrustedProxyAddresses { get; private set; } = Array.Empty<IPAddress>();
|
||||
|
||||
/// <summary>Vérifie les réglages au démarrage : un serveur mal configuré ne démarre pas.</summary>
|
||||
public void Validate(string contentRoot)
|
||||
{
|
||||
if (!Uri.TryCreate(PublicOrigin, UriKind.Absolute, out var origin) ||
|
||||
!string.IsNullOrEmpty(origin.Query) || origin.AbsolutePath != "/")
|
||||
throw new InvalidOperationException(
|
||||
$"{Section}:PublicOrigin doit être une adresse sans chemin, ex. « https://launcher.lanisys.fr ».");
|
||||
if (origin.Scheme != Uri.UriSchemeHttps && !(origin.Scheme == Uri.UriSchemeHttp && origin.IsLoopback && !RequireHttps))
|
||||
throw new InvalidOperationException($"{Section}:PublicOrigin doit être en https://.");
|
||||
NormalizedOrigin = Contracts.Security.SignedMessages.NormalizeOrigin(origin);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(DataDirectory))
|
||||
throw new InvalidOperationException($"{Section}:DataDirectory est obligatoire.");
|
||||
DataDirectoryFullPath = Path.GetFullPath(Path.IsPathRooted(DataDirectory)
|
||||
? DataDirectory
|
||||
: Path.Combine(contentRoot, DataDirectory));
|
||||
|
||||
var proxies = new List<IPAddress>();
|
||||
foreach (var proxy in TrustedProxies is { Length: > 0 } configured ? configured : DefaultTrustedProxies)
|
||||
{
|
||||
if (!IPAddress.TryParse(proxy, out var address))
|
||||
throw new InvalidOperationException($"{Section}:TrustedProxies : adresse invalide « {proxy} ».");
|
||||
proxies.Add(address);
|
||||
}
|
||||
TrustedProxyAddresses = proxies;
|
||||
|
||||
Require(Sessions.IdleMinutes is >= 1 and <= 240, "Sessions:IdleMinutes (1 à 240)");
|
||||
Require(Sessions.MaxHours is >= 1 and <= 24, "Sessions:MaxHours (1 à 24)");
|
||||
Require(Sessions.MaxPerAdmin is >= 1 and <= 50, "Sessions:MaxPerAdmin (1 à 50)");
|
||||
Require(Sessions.MaxTotal is >= 1 and <= 100_000, "Sessions:MaxTotal");
|
||||
Require(ChallengeLifetimeSeconds is >= 10 and <= 600, "ChallengeLifetimeSeconds (10 à 600)");
|
||||
Require(InvitationLifetimeHours is >= 1 and <= 168, "InvitationLifetimeHours (1 à 168)");
|
||||
Require(Limits.RequestsPerMinute >= 1 && Limits.AuthPerMinute >= 1 && Limits.EnrollmentsPerHour >= 1, "Limits");
|
||||
Require(Limits.MaxActiveInvitations is >= 1 and <= 1000, "Limits:MaxActiveInvitations");
|
||||
Require(Limits.MaxRequestBodyBytes is >= 1024 and <= 16 * 1024 * 1024, "Limits:MaxRequestBodyBytes");
|
||||
}
|
||||
|
||||
private static void Require(bool condition, string name)
|
||||
{
|
||||
if (!condition) throw new InvalidOperationException($"{Section}:{name} : valeur hors limites.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
using System.Threading.RateLimiting;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Server.Security;
|
||||
using GameLauncher.Server.Storage;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Construction du serveur : réglages, sécurité (proxy, HTTPS, en-têtes, limites, authentification)
|
||||
/// puis modules. Utilisé par Program et par les tests (serveur démarré en mémoire).
|
||||
/// </summary>
|
||||
public static class ServerApp
|
||||
{
|
||||
public static WebApplication Build(string[] args, Action<WebApplicationBuilder>? configure = null)
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
|
||||
{
|
||||
Args = args,
|
||||
ContentRootPath = AppContext.BaseDirectory // appsettings.json à côté de l'exécutable, quel que soit le dossier courant
|
||||
});
|
||||
configure?.Invoke(builder);
|
||||
|
||||
var options = builder.Configuration.GetSection(LauncherApiOptions.Section).Get<LauncherApiOptions>() ?? new LauncherApiOptions();
|
||||
options.Validate(builder.Environment.ContentRootPath);
|
||||
builder.Services.AddSingleton(Options.Create(options));
|
||||
builder.Services.TryAddTimeProvider();
|
||||
|
||||
builder.WebHost.ConfigureKestrel(kestrel =>
|
||||
{
|
||||
kestrel.AddServerHeader = false;
|
||||
kestrel.Limits.MaxRequestBodySize = options.Limits.MaxRequestBodyBytes;
|
||||
kestrel.Limits.MaxRequestHeaderCount = 40;
|
||||
kestrel.Limits.MaxRequestHeadersTotalSize = 16 * 1024;
|
||||
kestrel.Limits.RequestHeadersTimeout = TimeSpan.FromSeconds(15);
|
||||
});
|
||||
|
||||
builder.Services.Configure<ForwardedHeadersOptions>(forwarded =>
|
||||
{
|
||||
forwarded.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
||||
forwarded.ForwardLimit = 1;
|
||||
forwarded.KnownNetworks.Clear();
|
||||
forwarded.KnownProxies.Clear();
|
||||
foreach (var proxy in options.TrustedProxyAddresses) forwarded.KnownProxies.Add(proxy);
|
||||
});
|
||||
|
||||
builder.Services.ConfigureHttpJsonOptions(json =>
|
||||
{
|
||||
var source = ApiJson.Options;
|
||||
json.SerializerOptions.PropertyNamingPolicy = source.PropertyNamingPolicy;
|
||||
json.SerializerOptions.PropertyNameCaseInsensitive = source.PropertyNameCaseInsensitive;
|
||||
json.SerializerOptions.DefaultIgnoreCondition = source.DefaultIgnoreCondition;
|
||||
json.SerializerOptions.ReadCommentHandling = source.ReadCommentHandling;
|
||||
json.SerializerOptions.AllowTrailingCommas = source.AllowTrailingCommas;
|
||||
json.SerializerOptions.MaxDepth = source.MaxDepth;
|
||||
json.SerializerOptions.NumberHandling = source.NumberHandling;
|
||||
});
|
||||
// Corps JSON invalide : exception transformée en erreur 400 lisible (voir HandleErrors).
|
||||
builder.Services.Configure<Microsoft.AspNetCore.Routing.RouteHandlerOptions>(o => o.ThrowOnBadRequest = true);
|
||||
|
||||
builder.Services.AddRateLimiter(limiter =>
|
||||
{
|
||||
limiter.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
limiter.OnRejected = (context, _) => new ValueTask(ApiResults.WriteAsync(context.HttpContext, 429,
|
||||
ApiErrorCodes.RateLimited, "Trop de requêtes : patientez une minute avant de réessayer."));
|
||||
limiter.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
|
||||
RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Limits.RequestsPerMinute, TimeSpan.FromMinutes(1))));
|
||||
limiter.AddPolicy(RateLimitPolicies.Auth, context =>
|
||||
RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Limits.AuthPerMinute, TimeSpan.FromMinutes(1))));
|
||||
limiter.AddPolicy(RateLimitPolicies.Enrollment, context =>
|
||||
RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Limits.EnrollmentsPerHour, TimeSpan.FromHours(1))));
|
||||
});
|
||||
|
||||
// Protection des données d'ASP.NET Core (cookies, jetons anti-falsification) : inutilisée par l'API,
|
||||
// mais initialisée par le framework. Ses clés restent dans le dossier privé des données (rien dans $HOME).
|
||||
var dataProtection = Path.Combine(options.DataDirectoryFullPath, "data-protection");
|
||||
SecureFiles.EnsureDirectory(options.DataDirectoryFullPath);
|
||||
SecureFiles.EnsureDirectory(dataProtection);
|
||||
builder.Services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(dataProtection));
|
||||
builder.Logging.AddFilter("Microsoft.AspNetCore.DataProtection", LogLevel.Error);
|
||||
|
||||
builder.Services.AddSingleton<ServerIdentity>();
|
||||
builder.Services.AddSingleton<AdminStore>();
|
||||
builder.Services.AddAdminAuthentication();
|
||||
foreach (var module in ApiModules.All) module.ConfigureServices(builder.Services, options);
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseForwardedHeaders();
|
||||
app.Use(SecurityHeaders);
|
||||
app.Use(HandleErrors);
|
||||
app.Use((context, next) => RequireHttps(context, next, options));
|
||||
app.UseRateLimiter();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseRequestBodyCapture(); // après les limites et les droits : une requête refusée n'est pas lue
|
||||
|
||||
var api = app.MapGroup(ApiRoutes.Prefix);
|
||||
foreach (var module in ApiModules.All) module.MapEndpoints(api);
|
||||
app.MapFallback((HttpContext context) => ApiResults.NotFound("Adresse inconnue."));
|
||||
|
||||
// Identité et fichiers vérifiés dès le démarrage (un fichier illisible empêche le démarrage).
|
||||
var identity = app.Services.GetRequiredService<ServerIdentity>();
|
||||
var admins = app.Services.GetRequiredService<AdminStore>().Read();
|
||||
app.Logger.LogInformation("Launcher API : origine {Origin}, serveur {ServerId}, {Count} administrateur(s), données {Data}",
|
||||
identity.Origin, identity.ServerId, admins.Admins.Count, options.DataDirectoryFullPath);
|
||||
if (!admins.UsableOwners().Any())
|
||||
app.Logger.LogWarning("Aucun propriétaire actif : créez-le avec « GameLauncher.Server add-owner » (voir server/deploy/LISEZMOI.md).");
|
||||
return app;
|
||||
}
|
||||
|
||||
private static FixedWindowRateLimiterOptions Window(int permits, TimeSpan window) => new()
|
||||
{
|
||||
PermitLimit = permits,
|
||||
Window = window,
|
||||
QueueLimit = 0,
|
||||
AutoReplenishment = true
|
||||
};
|
||||
|
||||
private static void TryAddTimeProvider(this IServiceCollection services)
|
||||
{
|
||||
if (services.All(s => s.ServiceType != typeof(TimeProvider))) services.AddSingleton(TimeProvider.System);
|
||||
}
|
||||
|
||||
/// <summary>En-têtes de sécurité sur toutes les réponses (l'API ne sert jamais de page web).</summary>
|
||||
private static Task SecurityHeaders(HttpContext context, Func<Task> next)
|
||||
{
|
||||
context.Response.OnStarting(() =>
|
||||
{
|
||||
var headers = context.Response.Headers;
|
||||
headers.CacheControl = "no-store";
|
||||
headers.Pragma = "no-cache";
|
||||
headers.XContentTypeOptions = "nosniff";
|
||||
headers.XFrameOptions = "DENY";
|
||||
headers.ContentSecurityPolicy = "default-src 'none'; frame-ancestors 'none'";
|
||||
headers["Referrer-Policy"] = "no-referrer";
|
||||
headers["Cross-Origin-Resource-Policy"] = "same-origin";
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
return next();
|
||||
}
|
||||
|
||||
/// <summary>Exceptions → réponses <see cref="ApiError"/> sans détail interne (le détail va dans journalctl).</summary>
|
||||
private static async Task HandleErrors(HttpContext context, Func<Task> next)
|
||||
{
|
||||
try
|
||||
{
|
||||
await next();
|
||||
}
|
||||
catch (ApiRejection rejection) when (!context.Response.HasStarted)
|
||||
{
|
||||
await ApiResults.WriteAsync(context, rejection.Status, rejection.Code, rejection.Message);
|
||||
}
|
||||
catch (StoreBusyException ex) when (!context.Response.HasStarted)
|
||||
{
|
||||
context.RequestServices.GetRequiredService<ILogger<WebApplication>>().LogWarning("{Message}", ex.Message);
|
||||
await ApiResults.WriteAsync(context, 503, ApiErrorCodes.Busy, "Serveur momentanément occupé. Réessayez dans un instant.");
|
||||
}
|
||||
catch (BadHttpRequestException ex) when (!context.Response.HasStarted)
|
||||
{
|
||||
var status = ex.StatusCode == StatusCodes.Status413PayloadTooLarge ? 413 : 400;
|
||||
await ApiResults.WriteAsync(context, status, ApiErrorCodes.InvalidRequest,
|
||||
status == 413 ? "Requête trop volumineuse." : "Requête invalide.");
|
||||
}
|
||||
catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
|
||||
{
|
||||
// client parti
|
||||
}
|
||||
catch (Exception ex) when (!context.Response.HasStarted)
|
||||
{
|
||||
context.RequestServices.GetRequiredService<ILogger<WebApplication>>().LogError(ex, "Erreur sur {Path}", context.Request.Path);
|
||||
await ApiResults.WriteAsync(context, 500, ApiErrorCodes.Error, "Erreur interne du serveur.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HTTPS exigé (via nginx). Exception : la route de santé, appelée en HTTP depuis la machine
|
||||
/// elle-même (surveillance), sans passer par nginx.
|
||||
/// </summary>
|
||||
private static Task RequireHttps(HttpContext context, Func<Task> next, LauncherApiOptions options)
|
||||
{
|
||||
if (!options.RequireHttps || context.Request.IsHttps) return next();
|
||||
var local = context.Connection.RemoteIpAddress is { } ip && System.Net.IPAddress.IsLoopback(ip) &&
|
||||
!context.Request.Headers.ContainsKey("X-Forwarded-For");
|
||||
if (local && context.Request.Path.Equals(ApiRoutes.Prefix + "/" + ApiRoutes.Health, StringComparison.OrdinalIgnoreCase))
|
||||
return next();
|
||||
return ApiResults.WriteAsync(context, 400, ApiErrorCodes.HttpsRequired, "HTTPS obligatoire.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Security;
|
||||
using GameLauncher.Server.Storage;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Modules.Admins;
|
||||
|
||||
/// <summary>
|
||||
/// Gestion des administrateurs (permission admins.manage = propriétaires) : invitations,
|
||||
/// approbation des clés par empreinte, changement de rôle, révocation d'une clé ou d'un compte.
|
||||
/// Toutes les modifications sont des requêtes signées.
|
||||
///
|
||||
/// Nouvel appareil d'un compte actif : approuvé par le TITULAIRE du compte, depuis un de ses appareils
|
||||
/// déjà actifs. Personne d'autre ne peut ajouter une clé à son nom, pas même un propriétaire (qui
|
||||
/// pourrait sinon révoquer ses clés puis y ajouter la sienne). Compte sans aucun appareil actif :
|
||||
/// restauration sur le VPS (outil en ligne de commande, « add-key »).
|
||||
///
|
||||
/// Garde-fous : on ne change pas son propre rôle, on ne se révoque pas soi-même, on ne révoque pas
|
||||
/// la clé de sa propre session, et il reste toujours au moins un propriétaire capable de se connecter.
|
||||
/// </summary>
|
||||
public sealed class AdminsModule : IApiModule
|
||||
{
|
||||
public string Name => "Admins";
|
||||
|
||||
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) { }
|
||||
|
||||
public void MapEndpoints(IEndpointRouteBuilder api)
|
||||
{
|
||||
var manage = api.MapGroup("").RequirePermission(AdminPermissions.AdminsManage);
|
||||
|
||||
manage.MapGet(ApiRoutes.Admins, List);
|
||||
manage.MapGet(ApiRoutes.Invitations, ListInvitations);
|
||||
manage.MapDelete("v1/admins/invitations/{invitationId:guid}", DeleteInvitation).RequireSignedRequest();
|
||||
|
||||
// Ouvertes à tout administrateur connecté : chacun peut ajouter et approuver SES appareils ;
|
||||
// le reste exige admins.manage (contrôlé dans la modification elle-même, voir EnsureActor).
|
||||
api.MapPost(ApiRoutes.Invitations, CreateInvitation).RequireAdmin().RequireSignedRequest();
|
||||
api.MapPost("v1/admins/{adminId:guid}/keys/{keyId}/approve", ApproveKey).RequireAdmin().RequireSignedRequest();
|
||||
manage.MapPost("v1/admins/{adminId:guid}/keys/{keyId}/revoke", RevokeKey).RequireSignedRequest();
|
||||
manage.MapPost("v1/admins/{adminId:guid}/role", ChangeRole).RequireSignedRequest();
|
||||
manage.MapPost("v1/admins/{adminId:guid}/revoke", RevokeAdmin).RequireSignedRequest();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Lecture
|
||||
|
||||
private static IResult List(AdminStore store) =>
|
||||
Results.Json(store.Read().Admins.OrderBy(a => a.CreatedAt).Select(ToSummary).ToList(), ApiJson.Options);
|
||||
|
||||
private static IResult ListInvitations(AdminStore store, TimeProvider time)
|
||||
{
|
||||
var now = time.GetUtcNow();
|
||||
return Results.Json(store.Read().Invitations.Where(i => i.ExpiresAt > now).OrderBy(i => i.CreatedAt)
|
||||
.Select(ToSummary).ToList(), ApiJson.Options);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Invitations
|
||||
|
||||
private static IResult CreateInvitation(CreateInvitationRequest request, HttpContext context, AdminStore store,
|
||||
AuditLog audit, TimeProvider time, IOptions<LauncherApiOptions> options)
|
||||
{
|
||||
var actor = context.Admin();
|
||||
var now = time.GetUtcNow();
|
||||
var code = InvitationCodes.Generate();
|
||||
var limits = options.Value;
|
||||
|
||||
var invitation = store.Update(db =>
|
||||
{
|
||||
if (request.ForAdminId == actor.Admin.Id) EnsureActor(db, actor, AdminPermission.None); // son propre appareil
|
||||
else EnsureActor(db, actor);
|
||||
db.Invitations.RemoveAll(i => i.ExpiresAt <= now);
|
||||
if (!actor.Has(AdminPermissions.AdminsManage) && db.Invitations.Count(i => i.CreatedById == actor.Admin.Id) >= 2)
|
||||
throw ApiRejection.Conflict("Vous avez déjà 2 invitations en cours : utilisez-les ou attendez leur expiration.");
|
||||
if (db.Invitations.Count >= limits.Limits.MaxActiveInvitations)
|
||||
throw ApiRejection.Conflict("Trop d'invitations en cours : supprimez-en avant d'en créer une autre.");
|
||||
|
||||
string displayName, role;
|
||||
if (request.ForAdminId is { } adminId)
|
||||
{
|
||||
var target = db.FindAdmin(adminId) ?? throw ApiRejection.NotFound("Administrateur introuvable.");
|
||||
if (target.Status == AdminStatus.Revoked)
|
||||
throw ApiRejection.Conflict("Cet administrateur est révoqué.");
|
||||
(displayName, role) = (target.DisplayName, target.Role);
|
||||
}
|
||||
else
|
||||
{
|
||||
displayName = ApiText.CleanLine(request.DisplayName, ApiText.MaxDisplayNameLength)
|
||||
?? throw ApiRejection.BadRequest($"Nom obligatoire ({ApiText.MaxDisplayNameLength} caractères au plus).");
|
||||
role = AdminRoles.IsValid(request.Role) ? request.Role! : throw ApiRejection.BadRequest("Rôle inconnu.");
|
||||
}
|
||||
|
||||
var record = new InvitationRecord
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CodeHash = SignedMessages.HashInvitationCode(InvitationCodes.Normalize(code)!),
|
||||
DisplayName = displayName,
|
||||
Role = role,
|
||||
ForAdminId = request.ForAdminId,
|
||||
CreatedAt = now,
|
||||
CreatedBy = actor.Admin.DisplayName,
|
||||
CreatedById = actor.Admin.Id,
|
||||
ExpiresAt = now + TimeSpan.FromHours(limits.InvitationLifetimeHours)
|
||||
};
|
||||
db.Invitations.Add(record);
|
||||
return record;
|
||||
});
|
||||
|
||||
Audit(audit, context, "admins.invite", $"invitation {invitation.Id:D}",
|
||||
invitation.ForAdminId is null ? $"{invitation.DisplayName} ({invitation.Role})" : $"nouvel appareil pour {invitation.DisplayName}");
|
||||
return Results.Json(new InvitationCreated { Invitation = ToSummary(invitation), Code = code }, ApiJson.Options);
|
||||
}
|
||||
|
||||
private static IResult DeleteInvitation(Guid invitationId, HttpContext context, AdminStore store, AuditLog audit)
|
||||
{
|
||||
var actor = context.Admin();
|
||||
store.Update(db =>
|
||||
{
|
||||
EnsureActor(db, actor);
|
||||
if (db.Invitations.RemoveAll(i => i.Id == invitationId) == 0) throw ApiRejection.NotFound("Invitation introuvable.");
|
||||
});
|
||||
Audit(audit, context, "admins.invite_deleted", $"invitation {invitationId:D}");
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Clés
|
||||
|
||||
/// <summary>Approuve une clé en attente : l'empreinte saisie doit être celle de la clé.</summary>
|
||||
private static IResult ApproveKey(Guid adminId, string keyId, ApproveKeyRequest request, HttpContext context,
|
||||
AdminStore store, AuditLog audit, TimeProvider time)
|
||||
{
|
||||
var actor = context.Admin();
|
||||
var now = time.GetUtcNow();
|
||||
var name = store.Update(db =>
|
||||
{
|
||||
var (admin, key) = FindKey(db, adminId, keyId);
|
||||
// Nouvel appareil d'un compte déjà actif : seul son titulaire l'approuve (depuis un de ses
|
||||
// appareils actifs). S'il les a tous perdus : outil en ligne de commande sur le VPS (add-key).
|
||||
if (admin.Status == AdminStatus.Active && !admin.Keys.Any(k => k.Status == AdminStatus.Active && k.KeyId != key.KeyId))
|
||||
throw new ApiRejection(409, ApiErrorCodes.Conflict,
|
||||
$"{admin.DisplayName} n'a plus aucun appareil actif : restaurez son accès sur le VPS (« add-key »), " +
|
||||
"ou révoquez ce compte et invitez-le de nouveau.");
|
||||
if (admin.Status == AdminStatus.Active)
|
||||
{
|
||||
EnsureActor(db, actor, AdminPermission.None);
|
||||
if (actor.Admin.Id != admin.Id)
|
||||
throw new ApiRejection(403, ApiErrorCodes.Forbidden,
|
||||
$"Ce nouvel appareil doit être approuvé par {admin.DisplayName} lui-même, depuis un de ses appareils déjà actifs.");
|
||||
}
|
||||
else EnsureActor(db, actor);
|
||||
if (key.Status != AdminStatus.Pending) throw ApiRejection.Conflict("Cette clé n'est pas en attente d'approbation.");
|
||||
if (admin.Status == AdminStatus.Revoked) throw ApiRejection.Conflict("Cet administrateur est révoqué.");
|
||||
if (!AdminKeys.FingerprintMatches(key.PublicKeyBytes(), request.Fingerprint))
|
||||
{
|
||||
Audit(audit, context, "admins.key_approve", $"{admin.DisplayName} / clé {keyId}", "empreinte différente", AuditResults.Denied);
|
||||
throw new ApiRejection(400, ApiErrorCodes.FingerprintMismatch,
|
||||
"L'empreinte saisie ne correspond pas à cette clé. Ne l'approuvez pas : vérifiez-la de nouveau avec la personne invitée.");
|
||||
}
|
||||
|
||||
key.Status = AdminStatus.Active;
|
||||
key.ApprovedAt = now;
|
||||
key.ApprovedBy = actor.Admin.DisplayName;
|
||||
if (admin.Status == AdminStatus.Pending)
|
||||
{
|
||||
admin.Status = AdminStatus.Active;
|
||||
admin.ApprovedAt = now;
|
||||
admin.ApprovedBy = actor.Admin.DisplayName;
|
||||
}
|
||||
return admin.DisplayName;
|
||||
});
|
||||
Audit(audit, context, "admins.key_approve", $"{name} / clé {keyId}");
|
||||
return Results.Json(ToSummary(store.Read().FindAdmin(adminId)!), ApiJson.Options);
|
||||
}
|
||||
|
||||
private static IResult RevokeKey(Guid adminId, string keyId, HttpContext context, AdminStore store, SessionStore sessions,
|
||||
AuditLog audit, TimeProvider time)
|
||||
{
|
||||
var actor = context.Admin();
|
||||
if (string.Equals(keyId, actor.Key.KeyId, StringComparison.Ordinal))
|
||||
throw new ApiRejection(409, ApiErrorCodes.SelfAction, "Vous ne pouvez pas révoquer la clé de votre session en cours.");
|
||||
|
||||
var name = store.Update(db =>
|
||||
{
|
||||
EnsureActor(db, actor);
|
||||
var (admin, key) = FindKey(db, adminId, keyId);
|
||||
if (key.Status == AdminStatus.Revoked) throw ApiRejection.Conflict("Cette clé est déjà révoquée.");
|
||||
key.Status = AdminStatus.Revoked;
|
||||
key.RevokedAt = time.GetUtcNow();
|
||||
key.RevokedBy = actor.Admin.DisplayName;
|
||||
EnsureUsableOwner(db);
|
||||
return admin.DisplayName;
|
||||
});
|
||||
sessions.RemoveWhere(s => s.KeyId == keyId);
|
||||
Audit(audit, context, "admins.key_revoke", $"{name} / clé {keyId}");
|
||||
return Results.Json(ToSummary(store.Read().FindAdmin(adminId)!), ApiJson.Options);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Comptes
|
||||
|
||||
private static IResult ChangeRole(Guid adminId, ChangeRoleRequest request, HttpContext context, AdminStore store, AuditLog audit)
|
||||
{
|
||||
var actor = context.Admin();
|
||||
if (adminId == actor.Admin.Id)
|
||||
throw new ApiRejection(409, ApiErrorCodes.SelfAction, "Vous ne pouvez pas modifier votre propre rôle.");
|
||||
if (!AdminRoles.IsValid(request.Role)) throw ApiRejection.BadRequest("Rôle inconnu.");
|
||||
|
||||
var (name, previous) = store.Update(db =>
|
||||
{
|
||||
EnsureActor(db, actor);
|
||||
var admin = db.FindAdmin(adminId) ?? throw ApiRejection.NotFound("Administrateur introuvable.");
|
||||
if (admin.Status == AdminStatus.Revoked) throw ApiRejection.Conflict("Cet administrateur est révoqué.");
|
||||
var old = admin.Role;
|
||||
admin.Role = request.Role;
|
||||
EnsureUsableOwner(db);
|
||||
return (admin.DisplayName, old);
|
||||
});
|
||||
// Les droits sont relus à chaque requête : le nouveau rôle s'applique immédiatement.
|
||||
Audit(audit, context, "admins.role", name, $"{previous} → {request.Role}");
|
||||
return Results.Json(ToSummary(store.Read().FindAdmin(adminId)!), ApiJson.Options);
|
||||
}
|
||||
|
||||
private static IResult RevokeAdmin(Guid adminId, HttpContext context, AdminStore store, SessionStore sessions,
|
||||
AuditLog audit, TimeProvider time)
|
||||
{
|
||||
var actor = context.Admin();
|
||||
if (adminId == actor.Admin.Id)
|
||||
throw new ApiRejection(409, ApiErrorCodes.SelfAction, "Vous ne pouvez pas révoquer votre propre compte.");
|
||||
|
||||
var now = time.GetUtcNow();
|
||||
var name = store.Update(db =>
|
||||
{
|
||||
EnsureActor(db, actor);
|
||||
var admin = db.FindAdmin(adminId) ?? throw ApiRejection.NotFound("Administrateur introuvable.");
|
||||
if (admin.Status == AdminStatus.Revoked) throw ApiRejection.Conflict("Cet administrateur est déjà révoqué.");
|
||||
admin.Status = AdminStatus.Revoked;
|
||||
admin.RevokedAt = now;
|
||||
admin.RevokedBy = actor.Admin.DisplayName;
|
||||
foreach (var key in admin.Keys.Where(k => k.Status != AdminStatus.Revoked))
|
||||
{
|
||||
key.Status = AdminStatus.Revoked;
|
||||
key.RevokedAt = now;
|
||||
key.RevokedBy = actor.Admin.DisplayName;
|
||||
}
|
||||
db.Invitations.RemoveAll(i => i.ForAdminId == adminId);
|
||||
EnsureUsableOwner(db);
|
||||
return admin.DisplayName;
|
||||
});
|
||||
sessions.RemoveWhere(s => s.AdminId == adminId);
|
||||
Audit(audit, context, "admins.revoke", name);
|
||||
return Results.Json(ToSummary(store.Read().FindAdmin(adminId)!), ApiJson.Options);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Outils
|
||||
|
||||
private static (AdminRecord Admin, KeyRecord Key) FindKey(AdminDatabase db, Guid adminId, string keyId)
|
||||
{
|
||||
var admin = db.FindAdmin(adminId) ?? throw ApiRejection.NotFound("Administrateur introuvable.");
|
||||
var key = admin.Keys.FirstOrDefault(k => string.Equals(k.KeyId, keyId, StringComparison.Ordinal))
|
||||
?? throw ApiRejection.NotFound("Clé introuvable.");
|
||||
return (admin, key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Revérifie l'auteur DANS la modification (sous verrou) : s'il a été révoqué ou rétrogradé entre
|
||||
/// l'authentification de sa requête et l'écriture (autre propriétaire, outil en ligne de commande),
|
||||
/// rien n'est écrit.
|
||||
/// </summary>
|
||||
private static void EnsureActor(AdminDatabase db, AuthenticatedAdmin actor, AdminPermission required = AdminPermission.Manage)
|
||||
{
|
||||
if (db.FindKey(actor.Key.KeyId) is not { } current || current.Admin.Id != actor.Admin.Id ||
|
||||
current.Admin.Status != AdminStatus.Active || current.Key.Status != AdminStatus.Active ||
|
||||
(required == AdminPermission.Manage && !AdminRoles.Has(current.Admin.Role, AdminPermissions.AdminsManage)))
|
||||
throw new ApiRejection(403, ApiErrorCodes.Forbidden,
|
||||
required == AdminPermission.Manage ? "Vos droits ne permettent pas cette action." : "Vos droits ont changé entre-temps : action refusée.");
|
||||
}
|
||||
|
||||
private enum AdminPermission { None, Manage }
|
||||
|
||||
/// <summary>Annule la modification s'il ne reste plus aucun propriétaire capable de se connecter.</summary>
|
||||
private static void EnsureUsableOwner(AdminDatabase db)
|
||||
{
|
||||
if (!db.UsableOwners().Any())
|
||||
throw new ApiRejection(409, ApiErrorCodes.LastOwner,
|
||||
"Impossible : il doit toujours rester au moins un propriétaire actif disposant d'une clé active.");
|
||||
}
|
||||
|
||||
private static void Audit(AuditLog audit, HttpContext context, string action, string target, string? details = null,
|
||||
string result = AuditResults.Ok)
|
||||
{
|
||||
var actor = context.Admin();
|
||||
audit.Write(action, result, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(), target, details);
|
||||
}
|
||||
|
||||
internal static AdminSummary ToSummary(AdminRecord admin) => new()
|
||||
{
|
||||
Id = admin.Id,
|
||||
DisplayName = admin.DisplayName,
|
||||
Role = admin.Role,
|
||||
Status = admin.Status,
|
||||
CreatedAt = admin.CreatedAt,
|
||||
Keys = admin.Keys.Select(k => new AdminKeySummary
|
||||
{
|
||||
KeyId = k.KeyId,
|
||||
Label = k.Label,
|
||||
Status = k.Status,
|
||||
// Clé en attente : empreinte volontairement masquée (l'approbateur doit la recevoir de la personne invitée).
|
||||
Fingerprint = k.Status == AdminStatus.Pending ? null : AdminKeys.ComputeFingerprint(k.PublicKeyBytes()),
|
||||
AddedAt = k.AddedAt,
|
||||
LastUsedAt = k.LastUsedAt
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
private static InvitationSummary ToSummary(InvitationRecord invitation) => new()
|
||||
{
|
||||
Id = invitation.Id,
|
||||
DisplayName = invitation.DisplayName,
|
||||
Role = invitation.Role,
|
||||
ForAdminId = invitation.ForAdminId,
|
||||
CreatedAt = invitation.CreatedAt,
|
||||
CreatedBy = invitation.CreatedBy,
|
||||
ExpiresAt = invitation.ExpiresAt
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Security;
|
||||
using GameLauncher.Server.Storage;
|
||||
|
||||
namespace GameLauncher.Server.Modules.Audit;
|
||||
|
||||
/// <summary>Lecture du journal d'audit (permission audit.read), entrées les plus récentes d'abord.</summary>
|
||||
public sealed class AuditModule : IApiModule
|
||||
{
|
||||
public string Name => "Audit";
|
||||
|
||||
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) =>
|
||||
services.AddSingleton<AuditLog>();
|
||||
|
||||
public void MapEndpoints(IEndpointRouteBuilder api) =>
|
||||
api.MapGet(ApiRoutes.Audit, (int? limit, string? cursor, AuditLog audit) =>
|
||||
{
|
||||
if (cursor is { Length: > 32 }) return ApiResults.BadRequest("Curseur invalide.");
|
||||
return Results.Json(audit.Read(limit ?? 50, cursor), ApiJson.Options);
|
||||
}).RequirePermission(AdminPermissions.AuditRead);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
using System.Security.Cryptography;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Security;
|
||||
using GameLauncher.Server.Storage;
|
||||
|
||||
namespace GameLauncher.Server.Modules.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Authentification par clé : défi, connexion (signature du défi), session, déconnexion, et
|
||||
/// enrôlement d'une clé avec un code d'invitation (preuve de possession de la clé).
|
||||
/// </summary>
|
||||
public sealed class AuthModule : IApiModule
|
||||
{
|
||||
private const string LoginRefused = "Connexion refusée.";
|
||||
|
||||
public string Name => "Auth";
|
||||
|
||||
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options)
|
||||
{
|
||||
services.AddSingleton<ChallengeStore>();
|
||||
services.AddSingleton<SessionStore>();
|
||||
services.AddSingleton<ReplayCache>();
|
||||
}
|
||||
|
||||
public void MapEndpoints(IEndpointRouteBuilder api)
|
||||
{
|
||||
api.MapPost(ApiRoutes.AuthChallenge, Challenge).RequireRateLimiting(RateLimitPolicies.Auth);
|
||||
api.MapPost(ApiRoutes.AuthLogin, Login).RequireRateLimiting(RateLimitPolicies.Auth);
|
||||
api.MapPost(ApiRoutes.AuthLogout, Logout).RequireAdmin();
|
||||
api.MapGet(ApiRoutes.AuthMe, Me).RequireAdmin();
|
||||
api.MapPost(ApiRoutes.Enrollment, Enroll).RequireRateLimiting(RateLimitPolicies.Enrollment);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Défi de connexion. Même réponse que la clé soit connue ou non (pas d'énumération des clés) :
|
||||
/// la vérification a lieu à la connexion.
|
||||
/// </summary>
|
||||
private static IResult Challenge(ChallengeRequest request, ChallengeStore challenges, ServerIdentity server, TimeProvider time)
|
||||
{
|
||||
if (!AdminKeys.IsValidKeyId(request.KeyId)) return ApiResults.BadRequest("Identifiant de clé invalide.");
|
||||
var challenge = challenges.Create(request.KeyId);
|
||||
return Results.Json(new ChallengeResponse
|
||||
{
|
||||
ChallengeId = challenge.Id,
|
||||
Nonce = challenge.Nonce,
|
||||
ServerId = server.ServerId,
|
||||
Origin = server.Origin,
|
||||
ExpiresAt = challenge.ExpiresAt,
|
||||
ServerTime = time.GetUtcNow()
|
||||
}, ApiJson.Options);
|
||||
}
|
||||
|
||||
private static IResult Login(LoginRequest request, HttpContext context, ChallengeStore challenges, SessionStore sessions,
|
||||
AdminStore store, ServerIdentity server, AuditLog audit, TimeProvider time)
|
||||
{
|
||||
var ip = context.ClientIp();
|
||||
var signature = AdminKeys.DecodeSignature(request.Signature);
|
||||
if (!AdminKeys.IsValidKeyId(request.KeyId) || string.IsNullOrEmpty(request.ChallengeId) ||
|
||||
request.ChallengeId.Length > ChallengeStore.MaxIdLength || signature is null)
|
||||
return ApiResults.BadRequest("Demande de connexion incomplète.");
|
||||
|
||||
// 1. Échecs AVANT toute signature valide (défi, clé ou signature) : bruit possible, journal limité.
|
||||
AdminRecord? admin = null;
|
||||
KeyRecord? key = null;
|
||||
string? unsigned = null;
|
||||
if (!challenges.TryValidate(request.ChallengeId, request.KeyId, out var challenge))
|
||||
unsigned = "défi inconnu, expiré ou destiné à une autre clé";
|
||||
else if (store.Read().FindKey(request.KeyId) is not { } found)
|
||||
unsigned = "clé inconnue";
|
||||
else
|
||||
{
|
||||
(admin, key) = found;
|
||||
if (!AdminKeys.Verify(key.PublicKeyBytes(),
|
||||
SignedMessages.Login(server.Origin, server.ServerId, challenge.Id, challenge.Nonce, key.KeyId), signature))
|
||||
unsigned = "signature invalide";
|
||||
}
|
||||
if (unsigned is not null || admin is null || key is null)
|
||||
{
|
||||
audit.WriteAnonymousFailure("auth.login", request.KeyId, ip, unsigned);
|
||||
return ApiResults.Unauthorized(LoginRefused);
|
||||
}
|
||||
|
||||
// 2. Signature VALIDE mais connexion refusée (clé révoquée ou en attente, défi rejoué) : signal fort,
|
||||
// toujours journalisé en détail (ex. clé volée puis révoquée encore utilisée).
|
||||
// (Le défi n'est marqué « utilisé » qu'une fois la connexion acceptée : une clé révoquée ou en
|
||||
// attente ne peut pas remplir la liste des défis utilisés.)
|
||||
string? refused = null;
|
||||
if (admin.Status != AdminStatus.Active) refused = $"administrateur {admin.Status}";
|
||||
else if (key.Status != AdminStatus.Active) refused = $"clé {key.Status}";
|
||||
else if (!challenges.TryMarkUsed(challenge)) refused = "défi déjà utilisé (rejeu)";
|
||||
if (refused is not null)
|
||||
{
|
||||
audit.WriteSignedRefusal("auth.login", admin.Id, admin.DisplayName, key.KeyId, ip, refused);
|
||||
return ApiResults.Unauthorized(LoginRefused);
|
||||
}
|
||||
|
||||
var clientName = ApiText.CleanLine(request.ClientName, ApiText.MaxClientNameLength);
|
||||
var (token, session) = sessions.Create(admin.Id, key.KeyId, ip, clientName);
|
||||
// Date de dernière utilisation : enregistrée au plus une fois par heure (évite de réécrire
|
||||
// admins.json à chaque connexion).
|
||||
var now = time.GetUtcNow();
|
||||
if (key.LastUsedAt is null || now - key.LastUsedAt > TimeSpan.FromHours(1))
|
||||
{
|
||||
try
|
||||
{
|
||||
store.Update(db =>
|
||||
{
|
||||
if (db.FindKey(key.KeyId) is { } current) current.Key.LastUsedAt = now;
|
||||
});
|
||||
}
|
||||
catch (StoreBusyException)
|
||||
{
|
||||
// sans conséquence
|
||||
}
|
||||
}
|
||||
|
||||
audit.Write("auth.login", AuditResults.Ok, admin.Id, admin.DisplayName, key.KeyId, ip, details: clientName);
|
||||
var authenticated = new AuthenticatedAdmin(session, admin, key, AdminRoles.PermissionsOf(admin.Role));
|
||||
return Results.Json(new LoginResponse
|
||||
{
|
||||
Token = token,
|
||||
SessionId = session.Id,
|
||||
ExpiresAt = session.ExpiresAt,
|
||||
IdleExpiresAt = sessions.IdleExpiresAt(session),
|
||||
Admin = authenticated.ToProfile()
|
||||
}, ApiJson.Options);
|
||||
}
|
||||
|
||||
private static IResult Logout(HttpContext context, SessionStore sessions, AuditLog audit)
|
||||
{
|
||||
var admin = context.Admin();
|
||||
sessions.Remove(admin.Session);
|
||||
audit.Write("auth.logout", AuditResults.Ok, admin.Admin.Id, admin.Admin.DisplayName, admin.Key.KeyId, context.ClientIp());
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static IResult Me(HttpContext context, SessionStore sessions)
|
||||
{
|
||||
var admin = context.Admin();
|
||||
return Results.Json(new SessionInfo
|
||||
{
|
||||
SessionId = admin.Session.Id,
|
||||
ExpiresAt = admin.Session.ExpiresAt,
|
||||
IdleExpiresAt = sessions.IdleExpiresAt(admin.Session),
|
||||
Admin = admin.ToProfile()
|
||||
}, ApiJson.Options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enrôlement d'une clé avec un code d'invitation. La clé (et, pour une nouvelle personne, le
|
||||
/// compte) reste EN ATTENTE jusqu'à l'approbation par un propriétaire, qui doit saisir l'empreinte
|
||||
/// communiquée par la personne invitée. Le code est consommé (usage unique).
|
||||
/// </summary>
|
||||
private static IResult Enroll(EnrollmentRequest request, HttpContext context, AdminStore store, ServerIdentity server,
|
||||
AuditLog audit, TimeProvider time)
|
||||
{
|
||||
var ip = context.ClientIp();
|
||||
var code = InvitationCodes.Normalize(request.InvitationCode);
|
||||
var proof = AdminKeys.DecodeSignature(request.Proof);
|
||||
if (code is null || proof is null) return Reject("code ou preuve mal formés");
|
||||
|
||||
byte[] spki;
|
||||
try
|
||||
{
|
||||
spki = AdminKeys.ParsePublicKey(request.PublicKey);
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
return ApiResults.BadRequest("Clé publique invalide : une clé ECDSA P-256 est attendue.");
|
||||
}
|
||||
|
||||
var keyId = AdminKeys.ComputeKeyId(spki);
|
||||
var codeHash = SignedMessages.HashInvitationCode(code);
|
||||
if (!AdminKeys.Verify(spki, SignedMessages.Enrollment(server.Origin, server.ServerId, codeHash, keyId), proof))
|
||||
return Reject("preuve de possession invalide", keyId);
|
||||
|
||||
var label = ApiText.CleanLine(request.KeyLabel, ApiText.MaxKeyLabelLength);
|
||||
if (request.KeyLabel is not null && label is null)
|
||||
return ApiResults.BadRequest($"Nom du poste invalide ({ApiText.MaxKeyLabelLength} caractères au plus).");
|
||||
|
||||
var now = time.GetUtcNow();
|
||||
var result = store.Update(db =>
|
||||
{
|
||||
db.Invitations.RemoveAll(i => i.ExpiresAt <= now);
|
||||
var invitation = db.Invitations.FirstOrDefault(i => string.Equals(i.CodeHash, codeHash, StringComparison.Ordinal));
|
||||
if (invitation is null)
|
||||
{
|
||||
audit.WriteAnonymousFailure("admins.enroll", keyId, ip, "code inconnu, expiré ou déjà utilisé");
|
||||
throw new ApiRejection(400, ApiErrorCodes.InvitationInvalid, "Code d'invitation invalide, expiré ou déjà utilisé.");
|
||||
}
|
||||
if (db.FindKey(keyId) is not null)
|
||||
throw ApiRejection.Conflict("Cette clé est déjà enregistrée.");
|
||||
|
||||
var key = new KeyRecord
|
||||
{
|
||||
KeyId = keyId,
|
||||
PublicKey = Convert.ToBase64String(spki),
|
||||
Label = label,
|
||||
Status = AdminStatus.Pending,
|
||||
AddedAt = now
|
||||
};
|
||||
|
||||
AdminRecord admin;
|
||||
if (invitation.ForAdminId is { } existingId)
|
||||
{
|
||||
admin = db.FindAdmin(existingId) ?? throw new ApiRejection(400, ApiErrorCodes.InvitationInvalid,
|
||||
"Code d'invitation invalide, expiré ou déjà utilisé.");
|
||||
if (admin.Status == AdminStatus.Revoked)
|
||||
throw new ApiRejection(400, ApiErrorCodes.InvitationInvalid, "Code d'invitation invalide, expiré ou déjà utilisé.");
|
||||
}
|
||||
else
|
||||
{
|
||||
admin = new AdminRecord
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
DisplayName = invitation.DisplayName,
|
||||
Role = invitation.Role,
|
||||
Status = AdminStatus.Pending,
|
||||
CreatedAt = now,
|
||||
CreatedBy = invitation.CreatedBy
|
||||
};
|
||||
db.Admins.Add(admin);
|
||||
}
|
||||
admin.Keys.Add(key);
|
||||
db.Invitations.Remove(invitation); // usage unique
|
||||
return (admin.Id, admin.DisplayName, admin.Status, InvitationId: invitation.Id);
|
||||
});
|
||||
|
||||
audit.Write("admins.enroll", AuditResults.Ok, result.Id, result.DisplayName, keyId, ip,
|
||||
target: $"invitation {result.InvitationId:D}", details: label);
|
||||
return Results.Json(new EnrollmentResponse
|
||||
{
|
||||
AdminId = result.Id,
|
||||
DisplayName = result.DisplayName,
|
||||
KeyId = keyId,
|
||||
Fingerprint = AdminKeys.ComputeFingerprint(spki),
|
||||
Status = AdminStatus.Pending
|
||||
}, ApiJson.Options);
|
||||
|
||||
IResult Reject(string reason, string? key = null)
|
||||
{
|
||||
audit.WriteAnonymousFailure("admins.enroll", key, ip, reason);
|
||||
return ApiResults.Error(400, ApiErrorCodes.InvitationInvalid, "Code d'invitation invalide, expiré ou déjà utilisé.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Reflection;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Storage;
|
||||
|
||||
namespace GameLauncher.Server.Modules.System;
|
||||
|
||||
/// <summary>Informations publiques du serveur et état de santé.</summary>
|
||||
public sealed class SystemModule : IApiModule
|
||||
{
|
||||
public string Name => "System";
|
||||
|
||||
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) { }
|
||||
|
||||
public void MapEndpoints(IEndpointRouteBuilder api)
|
||||
{
|
||||
var version = typeof(SystemModule).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
|
||||
?? "?";
|
||||
var plus = version.IndexOf('+');
|
||||
if (plus > 0) version = version[..plus]; // sans l'empreinte du commit
|
||||
|
||||
api.MapGet(ApiRoutes.Info, (ServerIdentity server) => Results.Json(new ServerInfo
|
||||
{
|
||||
ServerId = server.ServerId,
|
||||
Origin = server.Origin,
|
||||
ApiVersion = ApiRoutes.Version,
|
||||
ServerVersion = version,
|
||||
KeyAlgorithms = new[] { AdminKeys.Algorithm }
|
||||
}, ApiJson.Options));
|
||||
|
||||
api.MapGet(ApiRoutes.Health, () => Results.Json(new HealthStatus(), ApiJson.Options));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using GameLauncher.Server.Cli;
|
||||
using GameLauncher.Server.Hosting;
|
||||
|
||||
namespace GameLauncher.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Sans commande : démarre la Launcher API (service systemd).
|
||||
/// Avec une commande (add-owner, list, revoke-admin, revoke-key, server-id, help) : outil
|
||||
/// d'administration en ligne de commande, à lancer SUR LE VPS sous le compte du service.
|
||||
/// </summary>
|
||||
public static class Program
|
||||
{
|
||||
public static async Task<int> Main(string[] args)
|
||||
{
|
||||
if (AdminCli.IsCommand(args)) return AdminCli.Run(args);
|
||||
|
||||
var app = ServerApp.Build(args);
|
||||
await app.RunAsync();
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"profiles": {
|
||||
"GameLauncher.Server": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://127.0.0.1:5080",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Storage;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Security;
|
||||
|
||||
/// <summary>Administrateur authentifié pour la requête en cours.</summary>
|
||||
public sealed record AuthenticatedAdmin(AdminSessionState Session, AdminRecord Admin, KeyRecord Key, IReadOnlyList<string> Permissions)
|
||||
{
|
||||
public bool Has(string permission) => Permissions.Contains(permission, StringComparer.Ordinal);
|
||||
|
||||
public AdminProfile ToProfile() => new()
|
||||
{
|
||||
Id = Admin.Id,
|
||||
DisplayName = Admin.DisplayName,
|
||||
Role = Admin.Role,
|
||||
Permissions = Permissions,
|
||||
KeyId = Key.KeyId,
|
||||
KeyLabel = Key.Label
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schéma « AdminSession » : jeton de session (Authorization: Bearer …). À chaque requête, la
|
||||
/// session doit être valide ET l'administrateur actif ET la clé de la session active : une
|
||||
/// révocation (même depuis l'outil en ligne de commande) prend effet immédiatement. Le rôle est relu
|
||||
/// à chaque requête : un changement de rôle s'applique aussitôt.
|
||||
/// </summary>
|
||||
public sealed class AdminSessionHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
public const string SchemeName = "AdminSession";
|
||||
public const string PermissionClaim = "perm";
|
||||
private const string ContextKey = "GameLauncher.Admin";
|
||||
|
||||
private readonly SessionStore _sessions;
|
||||
private readonly AdminStore _store;
|
||||
|
||||
public AdminSessionHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder,
|
||||
SessionStore sessions, AdminStore store)
|
||||
: base(options, logger, encoder)
|
||||
{
|
||||
_sessions = sessions;
|
||||
_store = store;
|
||||
}
|
||||
|
||||
public static AuthenticatedAdmin? Get(HttpContext context) =>
|
||||
context.Items.TryGetValue(ContextKey, out var value) ? value as AuthenticatedAdmin : null;
|
||||
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
var header = Request.Headers.Authorization.ToString();
|
||||
if (string.IsNullOrEmpty(header)) return Task.FromResult(AuthenticateResult.NoResult());
|
||||
if (!header.StartsWith("Bearer ", StringComparison.Ordinal))
|
||||
return Task.FromResult(AuthenticateResult.Fail("Schéma d'authentification inattendu."));
|
||||
|
||||
var session = _sessions.Find(header["Bearer ".Length..].Trim());
|
||||
if (session is null) return Task.FromResult(AuthenticateResult.Fail("Session absente ou expirée."));
|
||||
|
||||
var match = _store.Read().FindKey(session.KeyId);
|
||||
if (match is not { } found || found.Admin.Id != session.AdminId ||
|
||||
found.Admin.Status != AdminStatus.Active || found.Key.Status != AdminStatus.Active)
|
||||
{
|
||||
_sessions.Remove(session);
|
||||
return Task.FromResult(AuthenticateResult.Fail("Administrateur ou clé révoqué."));
|
||||
}
|
||||
|
||||
var permissions = AdminRoles.PermissionsOf(found.Admin.Role);
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, found.Admin.Id.ToString("D")),
|
||||
new(ClaimTypes.Name, found.Admin.DisplayName),
|
||||
new(ClaimTypes.Role, found.Admin.Role),
|
||||
new("key", found.Key.KeyId),
|
||||
new("sid", session.Id)
|
||||
};
|
||||
claims.AddRange(permissions.Select(p => new Claim(PermissionClaim, p)));
|
||||
|
||||
Context.Items[ContextKey] = new AuthenticatedAdmin(session, found.Admin, found.Key, permissions);
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, SchemeName));
|
||||
return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(principal, SchemeName)));
|
||||
}
|
||||
|
||||
protected override Task HandleChallengeAsync(AuthenticationProperties properties) =>
|
||||
ApiResults.WriteAsync(Context, 401, ApiErrorCodes.Unauthorized, "Authentification requise (session absente ou expirée).");
|
||||
|
||||
protected override Task HandleForbiddenAsync(AuthenticationProperties properties) =>
|
||||
ApiResults.WriteAsync(Context, 403, ApiErrorCodes.Forbidden, "Vos droits ne permettent pas cette action.");
|
||||
}
|
||||
|
||||
public static class AdminAuthorization
|
||||
{
|
||||
public static string PolicyName(string permission) => "perm:" + permission;
|
||||
|
||||
/// <summary>Route réservée aux administrateurs disposant de cette permission.</summary>
|
||||
public static TBuilder RequirePermission<TBuilder>(this TBuilder builder, string permission)
|
||||
where TBuilder : IEndpointConventionBuilder =>
|
||||
builder.RequireAuthorization(PolicyName(permission));
|
||||
|
||||
/// <summary>Route réservée à un administrateur connecté (quelles que soient ses permissions).</summary>
|
||||
public static TBuilder RequireAdmin<TBuilder>(this TBuilder builder) where TBuilder : IEndpointConventionBuilder =>
|
||||
builder.RequireAuthorization(AdminSessionHandler.SchemeName);
|
||||
|
||||
public static IServiceCollection AddAdminAuthentication(this IServiceCollection services)
|
||||
{
|
||||
services.AddAuthentication(AdminSessionHandler.SchemeName)
|
||||
.AddScheme<AuthenticationSchemeOptions, AdminSessionHandler>(AdminSessionHandler.SchemeName, null);
|
||||
services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddPolicy(AdminSessionHandler.SchemeName, p => p.RequireAuthenticatedUser());
|
||||
foreach (var permission in AdminPermissions.All)
|
||||
options.AddPolicy(PolicyName(permission),
|
||||
p => p.RequireAuthenticatedUser().RequireClaim(AdminSessionHandler.PermissionClaim, permission));
|
||||
});
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>Administrateur authentifié de la requête (les routes protégées en ont toujours un).</summary>
|
||||
public static AuthenticatedAdmin Admin(this HttpContext context) =>
|
||||
AdminSessionHandler.Get(context) ?? throw new InvalidOperationException("Route non protégée par RequireAdmin/RequirePermission.");
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Storage;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Security;
|
||||
|
||||
/// <summary>Défi de connexion : à signer avec la clé, valable quelques secondes, utilisable une seule fois.</summary>
|
||||
public sealed record LoginChallenge(string Id, string Nonce, string KeyId, DateTimeOffset ExpiresAt);
|
||||
|
||||
/// <summary>
|
||||
/// Défis de connexion SANS ÉTAT : l'identifiant du défi contient la clé visée, l'échéance et le
|
||||
/// nonce, scellés par un HMAC (secret aléatoire, en mémoire). Il n'existe donc aucune réserve
|
||||
/// partagée qu'un attaquant pourrait remplir ou vider : demander des défis ne coûte rien au serveur.
|
||||
/// Seuls les défis UTILISÉS avec succès sont mémorisés (jusqu'à leur échéance) pour refuser le rejeu ;
|
||||
/// on n'y entre qu'avec une signature valide.
|
||||
/// </summary>
|
||||
public sealed class ChallengeStore
|
||||
{
|
||||
private const int NonceLength = 32;
|
||||
private const int MacLength = 16;
|
||||
private const int PayloadLength = 8 + 8 + NonceLength; // clé (8 octets) + échéance + nonce
|
||||
private static readonly byte[] Domain = Encoding.ASCII.GetBytes("GameLauncher/Admin/Challenge/v1\n");
|
||||
|
||||
private readonly byte[] _secret = RandomNumberGenerator.GetBytes(32);
|
||||
private readonly ConcurrentDictionary<string, DateTimeOffset> _used = new(StringComparer.Ordinal);
|
||||
private readonly TimeProvider _time;
|
||||
private readonly TimeSpan _lifetime;
|
||||
private readonly int _maxUsed;
|
||||
|
||||
public ChallengeStore(IOptions<LauncherApiOptions> options, TimeProvider time)
|
||||
{
|
||||
_time = time;
|
||||
_lifetime = TimeSpan.FromSeconds(options.Value.ChallengeLifetimeSeconds);
|
||||
_maxUsed = 100_000;
|
||||
}
|
||||
|
||||
/// <summary>Longueur maximale d'un identifiant de défi (contrôle des entrées).</summary>
|
||||
public const int MaxIdLength = 96;
|
||||
|
||||
public LoginChallenge Create(string keyId)
|
||||
{
|
||||
var expires = _time.GetUtcNow() + _lifetime;
|
||||
var payload = new byte[PayloadLength];
|
||||
Convert.FromHexString(keyId).CopyTo(payload, 0);
|
||||
BinaryPrimitives.WriteInt64BigEndian(payload.AsSpan(8), expires.ToUnixTimeSeconds());
|
||||
RandomNumberGenerator.Fill(payload.AsSpan(16));
|
||||
|
||||
var id = new byte[PayloadLength + MacLength];
|
||||
payload.CopyTo(id, 0);
|
||||
Mac(payload).CopyTo(id, PayloadLength);
|
||||
return new LoginChallenge(Base64Url.Encode(id), Base64Url.Encode(payload.AsSpan(16)), keyId,
|
||||
DateTimeOffset.FromUnixTimeSeconds(expires.ToUnixTimeSeconds()));
|
||||
}
|
||||
|
||||
/// <summary>Vérifie le sceau, la clé visée et l'échéance (sans consommer le défi).</summary>
|
||||
public bool TryValidate(string challengeId, string keyId, out LoginChallenge challenge)
|
||||
{
|
||||
challenge = null!;
|
||||
if (challengeId.Length > MaxIdLength || !Base64Url.TryDecode(challengeId, out var id) ||
|
||||
id.Length != PayloadLength + MacLength || !AdminKeysHex(keyId, out var keyBytes))
|
||||
return false;
|
||||
|
||||
var payload = id.AsSpan(0, PayloadLength);
|
||||
if (!CryptographicOperations.FixedTimeEquals(Mac(payload), id.AsSpan(PayloadLength))) return false;
|
||||
if (!payload[..8].SequenceEqual(keyBytes)) return false;
|
||||
var expires = DateTimeOffset.FromUnixTimeSeconds(BinaryPrimitives.ReadInt64BigEndian(payload[8..16]));
|
||||
if (expires <= _time.GetUtcNow() || expires > _time.GetUtcNow() + _lifetime + TimeSpan.FromSeconds(1)) return false;
|
||||
|
||||
challenge = new LoginChallenge(challengeId, Base64Url.Encode(payload[16..]), keyId, expires);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Marque le défi comme utilisé (après vérification de la signature). Faux s'il l'était déjà (rejeu).</summary>
|
||||
public bool TryMarkUsed(LoginChallenge challenge)
|
||||
{
|
||||
if (_used.Count >= _maxUsed)
|
||||
{
|
||||
var now = _time.GetUtcNow();
|
||||
foreach (var (id, expires) in _used)
|
||||
if (expires <= now) _used.TryRemove(id, out _);
|
||||
if (_used.Count >= _maxUsed) throw new StoreBusyException("Trop de connexions récentes.");
|
||||
}
|
||||
return _used.TryAdd(challenge.Id, challenge.ExpiresAt);
|
||||
}
|
||||
|
||||
private byte[] Mac(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
var data = new byte[Domain.Length + payload.Length];
|
||||
Domain.CopyTo(data, 0);
|
||||
payload.CopyTo(data.AsSpan(Domain.Length));
|
||||
return HMACSHA256.HashData(_secret, data)[..MacLength];
|
||||
}
|
||||
|
||||
private static bool AdminKeysHex(string keyId, out byte[] bytes)
|
||||
{
|
||||
bytes = Array.Empty<byte>();
|
||||
if (!Contracts.Security.AdminKeys.IsValidKeyId(keyId)) return false;
|
||||
bytes = Convert.FromHexString(keyId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System.Collections.Concurrent;
|
||||
using GameLauncher.Server.Storage;
|
||||
|
||||
namespace GameLauncher.Server.Security;
|
||||
|
||||
/// <summary>Nonces des requêtes signées déjà vus (pendant la fenêtre de tolérance d'horloge) : refus des rejeux.</summary>
|
||||
public sealed class ReplayCache
|
||||
{
|
||||
private const int Max = 100_000;
|
||||
private readonly ConcurrentDictionary<string, DateTimeOffset> _seen = new(StringComparer.Ordinal);
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
public ReplayCache(TimeProvider time) => _time = time;
|
||||
|
||||
/// <summary>Faux si ce nonce a déjà été utilisé.</summary>
|
||||
public bool TryRegister(string key, TimeSpan keepFor)
|
||||
{
|
||||
var now = _time.GetUtcNow();
|
||||
if (_seen.Count >= Max)
|
||||
{
|
||||
foreach (var (k, expires) in _seen)
|
||||
if (expires <= now) _seen.TryRemove(k, out _);
|
||||
if (_seen.Count >= Max) throw new StoreBusyException("Trop de requêtes signées récentes.");
|
||||
}
|
||||
|
||||
if (_seen.TryAdd(key, now + keepFor)) return true;
|
||||
// Entrée expirée encore présente : elle peut être remplacée.
|
||||
if (_seen.TryGetValue(key, out var existing) && existing <= now && _seen.TryUpdate(key, now + keepFor, existing))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Storage;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Security;
|
||||
|
||||
/// <summary>Session d'un administrateur (en mémoire uniquement).</summary>
|
||||
public sealed class AdminSessionState
|
||||
{
|
||||
/// <summary>Identifiant public de la session (inclus dans les requêtes signées).</summary>
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>SHA-256 du jeton : le jeton lui-même n'est jamais conservé.</summary>
|
||||
public required string TokenHash { get; init; }
|
||||
|
||||
public required Guid AdminId { get; init; }
|
||||
public required string KeyId { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
public required DateTimeOffset ExpiresAt { get; init; }
|
||||
public string? Ip { get; init; }
|
||||
public string? ClientName { get; init; }
|
||||
|
||||
private long _lastSeenTicks;
|
||||
|
||||
public DateTimeOffset LastSeenAt
|
||||
{
|
||||
get => new(Interlocked.Read(ref _lastSeenTicks), TimeSpan.Zero);
|
||||
set => Interlocked.Exchange(ref _lastSeenTicks, value.UtcTicks);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sessions en mémoire : un redémarrage du service déconnecte tout le monde. Durée d'inactivité
|
||||
/// et durée maximale bornées ; nombre de sessions borné par administrateur et au total.
|
||||
/// </summary>
|
||||
public sealed class SessionStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, AdminSessionState> _byTokenHash = new(StringComparer.Ordinal);
|
||||
private readonly object _createLock = new();
|
||||
private readonly TimeProvider _time;
|
||||
private readonly LauncherApiOptions.SessionSettings _settings;
|
||||
|
||||
public SessionStore(IOptions<LauncherApiOptions> options, TimeProvider time)
|
||||
{
|
||||
_time = time;
|
||||
_settings = options.Value.Sessions;
|
||||
}
|
||||
|
||||
public TimeSpan IdleTimeout => TimeSpan.FromMinutes(_settings.IdleMinutes);
|
||||
|
||||
public int Count => _byTokenHash.Count;
|
||||
|
||||
/// <summary>Crée une session ; renvoie le jeton (à transmettre une seule fois au client).</summary>
|
||||
public (string Token, AdminSessionState Session) Create(Guid adminId, string keyId, string? ip, string? clientName)
|
||||
{
|
||||
var now = _time.GetUtcNow();
|
||||
var token = Base64Url.Encode(RandomNumberGenerator.GetBytes(32));
|
||||
var session = new AdminSessionState
|
||||
{
|
||||
Id = Base64Url.Encode(RandomNumberGenerator.GetBytes(16)),
|
||||
TokenHash = Hash(token),
|
||||
AdminId = adminId,
|
||||
KeyId = keyId,
|
||||
CreatedAt = now,
|
||||
ExpiresAt = now + TimeSpan.FromHours(_settings.MaxHours),
|
||||
Ip = ip,
|
||||
ClientName = clientName,
|
||||
LastSeenAt = now
|
||||
};
|
||||
|
||||
lock (_createLock)
|
||||
{
|
||||
RemoveExpired();
|
||||
// Au-delà du nombre autorisé pour cet administrateur : les plus anciennes sont fermées.
|
||||
var own = _byTokenHash.Values.Where(s => s.AdminId == adminId).OrderBy(s => s.CreatedAt).ToList();
|
||||
foreach (var old in own.Take(Math.Max(0, own.Count - _settings.MaxPerAdmin + 1)))
|
||||
_byTokenHash.TryRemove(old.TokenHash, out _);
|
||||
if (_byTokenHash.Count >= _settings.MaxTotal) throw new StoreBusyException("Trop de sessions ouvertes.");
|
||||
_byTokenHash[session.TokenHash] = session;
|
||||
}
|
||||
return (token, session);
|
||||
}
|
||||
|
||||
/// <summary>Session correspondant au jeton, si elle est encore valide ; l'activité est enregistrée.</summary>
|
||||
public AdminSessionState? Find(string? token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(token) || token.Length > 256) return null;
|
||||
var hash = Hash(token);
|
||||
if (!_byTokenHash.TryGetValue(hash, out var session)) return null;
|
||||
|
||||
var now = _time.GetUtcNow();
|
||||
if (IsExpired(session, now))
|
||||
{
|
||||
_byTokenHash.TryRemove(hash, out _);
|
||||
return null;
|
||||
}
|
||||
session.LastSeenAt = now;
|
||||
return session;
|
||||
}
|
||||
|
||||
public DateTimeOffset IdleExpiresAt(AdminSessionState session)
|
||||
{
|
||||
var idle = session.LastSeenAt + IdleTimeout;
|
||||
return idle < session.ExpiresAt ? idle : session.ExpiresAt;
|
||||
}
|
||||
|
||||
public void Remove(AdminSessionState session) => _byTokenHash.TryRemove(session.TokenHash, out _);
|
||||
|
||||
public int RemoveWhere(Func<AdminSessionState, bool> predicate)
|
||||
{
|
||||
var removed = 0;
|
||||
foreach (var (hash, session) in _byTokenHash)
|
||||
if (predicate(session) && _byTokenHash.TryRemove(hash, out _)) removed++;
|
||||
return removed;
|
||||
}
|
||||
|
||||
public void RemoveExpired()
|
||||
{
|
||||
var now = _time.GetUtcNow();
|
||||
RemoveWhere(s => IsExpired(s, now));
|
||||
}
|
||||
|
||||
private bool IsExpired(AdminSessionState session, DateTimeOffset now) =>
|
||||
now >= session.ExpiresAt || now - session.LastSeenAt >= IdleTimeout;
|
||||
|
||||
private static string Hash(string token) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Storage;
|
||||
|
||||
namespace GameLauncher.Server.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Requêtes signées : toute route qui modifie quelque chose exige, en plus de la session, une
|
||||
/// signature de la clé de l'administrateur sur la méthode, le chemin, le corps, l'heure et un nonce
|
||||
/// (<see cref="SignedMessages.Request"/>). Un jeton de session volé ne permet donc aucune modification.
|
||||
/// </summary>
|
||||
public static class SignedRequests
|
||||
{
|
||||
private const string BodyKey = "GameLauncher.Body";
|
||||
|
||||
/// <summary>
|
||||
/// Middleware : copie en mémoire le corps des requêtes qui modifient (taille bornée par Kestrel),
|
||||
/// pour pouvoir le signer après la lecture JSON par la route.
|
||||
/// </summary>
|
||||
public static IApplicationBuilder UseRequestBodyCapture(this IApplicationBuilder app) => app.Use(async (context, next) =>
|
||||
{
|
||||
if (!HttpMethods.IsGet(context.Request.Method) && !HttpMethods.IsHead(context.Request.Method) &&
|
||||
!HttpMethods.IsOptions(context.Request.Method))
|
||||
{
|
||||
using var buffer = new MemoryStream();
|
||||
await context.Request.Body.CopyToAsync(buffer, context.RequestAborted);
|
||||
var body = buffer.ToArray();
|
||||
context.Items[BodyKey] = body;
|
||||
context.Request.Body = new MemoryStream(body, writable: false);
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
/// <summary>La route exige une requête signée par la clé de la session (à placer après RequireAdmin/RequirePermission).</summary>
|
||||
public static TBuilder RequireSignedRequest<TBuilder>(this TBuilder builder) where TBuilder : IEndpointConventionBuilder =>
|
||||
builder.AddEndpointFilter(async (invocation, next) =>
|
||||
{
|
||||
var context = invocation.HttpContext;
|
||||
var services = context.RequestServices;
|
||||
var failure = Verify(context, services.GetRequiredService<ServerIdentity>(), services.GetRequiredService<ReplayCache>(),
|
||||
services.GetRequiredService<TimeProvider>());
|
||||
if (failure is null) return await next(invocation);
|
||||
|
||||
var admin = AdminSessionHandler.Get(context);
|
||||
services.GetRequiredService<AuditLog>().Write("request.signature", AuditResults.Denied, admin?.Admin.Id,
|
||||
admin?.Admin.DisplayName, admin?.Key.KeyId, context.ClientIp(), $"{context.Request.Method} {context.Request.Path}",
|
||||
failure.Value.Message);
|
||||
return ApiResults.Unauthorized(failure.Value.Message, failure.Value.Code);
|
||||
});
|
||||
|
||||
private static (string Code, string Message)? Verify(HttpContext context, ServerIdentity server, ReplayCache replays, TimeProvider time)
|
||||
{
|
||||
var admin = AdminSessionHandler.Get(context);
|
||||
if (admin is null) return (ApiErrorCodes.Unauthorized, "Authentification requise.");
|
||||
|
||||
var headers = context.Request.Headers;
|
||||
var timestampText = headers[SignedRequestHeaders.Timestamp].ToString();
|
||||
var nonce = headers[SignedRequestHeaders.Nonce].ToString();
|
||||
var signature = AdminKeys.DecodeSignature(headers[SignedRequestHeaders.Signature].ToString());
|
||||
if (string.IsNullOrEmpty(timestampText) || string.IsNullOrEmpty(nonce) || signature is null)
|
||||
return (ApiErrorCodes.SignatureRequired, "Cette action doit être signée par votre clé.");
|
||||
|
||||
if (!long.TryParse(timestampText, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var timestamp))
|
||||
return (ApiErrorCodes.SignatureInvalid, "Horodatage de signature invalide.");
|
||||
if (nonce.Length is < 16 or > 64 || !Base64Url.TryDecode(nonce, out _))
|
||||
return (ApiErrorCodes.SignatureInvalid, "Nonce de signature invalide.");
|
||||
|
||||
var now = time.GetUtcNow();
|
||||
var skew = SignedRequestHeaders.MaxClockSkew;
|
||||
DateTimeOffset signedAt;
|
||||
try
|
||||
{
|
||||
signedAt = DateTimeOffset.FromUnixTimeSeconds(timestamp);
|
||||
}
|
||||
catch (ArgumentOutOfRangeException)
|
||||
{
|
||||
return (ApiErrorCodes.SignatureInvalid, "Horodatage de signature invalide.");
|
||||
}
|
||||
if (signedAt < now - skew || signedAt > now + skew)
|
||||
return (ApiErrorCodes.ClockSkew, "L'horloge de votre PC est décalée de plus de 2 minutes : corrigez l'heure de Windows.");
|
||||
|
||||
var body = context.Items.TryGetValue(BodyKey, out var captured) && captured is byte[] bytes ? bytes : Array.Empty<byte>();
|
||||
var request = context.Request;
|
||||
var message = SignedMessages.Request(server.Origin, server.ServerId, admin.Session.Id, request.Method,
|
||||
request.PathBase.Add(request.Path).ToUriComponent() + request.QueryString.ToUriComponent(), body, timestamp, nonce);
|
||||
if (!AdminKeys.Verify(admin.Key.PublicKeyBytes(), message, signature))
|
||||
return (ApiErrorCodes.SignatureInvalid, "Signature de la requête invalide.");
|
||||
|
||||
// Nonce enregistré seulement une fois la signature vérifiée.
|
||||
if (!replays.TryRegister(admin.Session.Id + ":" + nonce, skew + skew + TimeSpan.FromMinutes(1)))
|
||||
return (ApiErrorCodes.Replay, "Requête déjà reçue (rejeu refusé).");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Contenu de admins.json. Ne contient AUCUN secret : clés publiques et empreintes de codes
|
||||
/// d'invitation uniquement.
|
||||
/// </summary>
|
||||
public sealed class AdminDatabase
|
||||
{
|
||||
public int FormatVersion { get; set; } = 1;
|
||||
public List<AdminRecord> Admins { get; set; } = new();
|
||||
public List<InvitationRecord> Invitations { get; set; } = new();
|
||||
|
||||
public AdminRecord? FindAdmin(Guid id) => Admins.FirstOrDefault(a => a.Id == id);
|
||||
|
||||
/// <summary>Administrateur possédant cette clé (quel que soit l'état de la clé).</summary>
|
||||
public (AdminRecord Admin, KeyRecord Key)? FindKey(string keyId)
|
||||
{
|
||||
foreach (var admin in Admins)
|
||||
foreach (var key in admin.Keys)
|
||||
if (string.Equals(key.KeyId, keyId, StringComparison.Ordinal)) return (admin, key);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Propriétaires actifs disposant d'au moins une clé active (ceux qui peuvent encore se connecter).</summary>
|
||||
public IEnumerable<AdminRecord> UsableOwners() =>
|
||||
Admins.Where(a => a.Role == AdminRoles.Owner && a.Status == AdminStatus.Active &&
|
||||
a.Keys.Any(k => k.Status == AdminStatus.Active));
|
||||
}
|
||||
|
||||
public sealed class AdminRecord
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string DisplayName { get; set; } = "";
|
||||
public string Role { get; set; } = AdminRoles.Editor;
|
||||
public string Status { get; set; } = AdminStatus.Pending;
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public string? CreatedBy { get; set; }
|
||||
public DateTimeOffset? ApprovedAt { get; set; }
|
||||
public string? ApprovedBy { get; set; }
|
||||
public DateTimeOffset? RevokedAt { get; set; }
|
||||
public string? RevokedBy { get; set; }
|
||||
public List<KeyRecord> Keys { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class KeyRecord
|
||||
{
|
||||
public string KeyId { get; set; } = "";
|
||||
|
||||
/// <summary>Clé publique : SPKI en Base64.</summary>
|
||||
public string PublicKey { get; set; } = "";
|
||||
|
||||
public string? Label { get; set; }
|
||||
public string Status { get; set; } = AdminStatus.Pending;
|
||||
public DateTimeOffset AddedAt { get; set; }
|
||||
public DateTimeOffset? LastUsedAt { get; set; }
|
||||
public DateTimeOffset? ApprovedAt { get; set; }
|
||||
public string? ApprovedBy { get; set; }
|
||||
public DateTimeOffset? RevokedAt { get; set; }
|
||||
public string? RevokedBy { get; set; }
|
||||
|
||||
public byte[] PublicKeyBytes() => Convert.FromBase64String(PublicKey);
|
||||
}
|
||||
|
||||
public sealed class InvitationRecord
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>Empreinte du code (<see cref="SignedMessages.HashInvitationCode"/>) ; le code n'est jamais stocké.</summary>
|
||||
public string CodeHash { get; set; } = "";
|
||||
|
||||
public string DisplayName { get; set; } = "";
|
||||
public string Role { get; set; } = AdminRoles.Editor;
|
||||
|
||||
/// <summary>Invitation « nouvel appareil » pour un administrateur existant.</summary>
|
||||
public Guid? ForAdminId { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public string? CreatedBy { get; set; }
|
||||
public Guid? CreatedById { get; set; }
|
||||
public DateTimeOffset ExpiresAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Administrateurs et invitations (admins.json dans le dossier des données).
|
||||
/// • Lecture : copie en cache, relue automatiquement si le fichier a changé (outil en ligne de
|
||||
/// commande) — une révocation faite sur le VPS prend effet sans redémarrer le service.
|
||||
/// • Écriture : <see cref="Update{T}"/> relit le fichier sous verrou inter-processus, applique la
|
||||
/// modification sur une copie, puis remplace le fichier atomiquement (ancienne version en .bak).
|
||||
/// • Fichier illisible : le serveur refuse tout (aucune reprise silencieuse d'une sauvegarde, qui
|
||||
/// pourrait réactiver un administrateur révoqué).
|
||||
/// </summary>
|
||||
public sealed class AdminStore
|
||||
{
|
||||
public const string FileName = "admins.json";
|
||||
|
||||
private static readonly JsonSerializerOptions Json = new(Contracts.ApiJson.Options) { WriteIndented = true };
|
||||
private static readonly TimeSpan LockTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
private readonly string _path;
|
||||
private readonly string _lockPath;
|
||||
private readonly object _cacheLock = new(); // court : échange du cache
|
||||
private readonly object _writeLock = new(); // un seul écrivain dans ce processus
|
||||
private AdminDatabase? _cache;
|
||||
private (DateTime WriteTime, long Length) _cacheStamp;
|
||||
|
||||
public AdminStore(IOptions<LauncherApiOptions> options) : this(options.Value.DataDirectoryFullPath) { }
|
||||
|
||||
public AdminStore(string dataDirectory)
|
||||
{
|
||||
SecureFiles.EnsureDirectory(dataDirectory);
|
||||
_path = Path.Combine(dataDirectory, FileName);
|
||||
_lockPath = _path + ".lock";
|
||||
}
|
||||
|
||||
public string FilePath => _path;
|
||||
|
||||
/// <summary>
|
||||
/// Instantané en lecture seule (ne pas le modifier : utiliser <see cref="Update{T}"/>).
|
||||
/// </summary>
|
||||
public AdminDatabase Read()
|
||||
{
|
||||
lock (_cacheLock)
|
||||
{
|
||||
var stamp = Stamp();
|
||||
if (_cache is not null && stamp == _cacheStamp) return _cache;
|
||||
_cache = Load();
|
||||
_cacheStamp = stamp;
|
||||
return _cache;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Modification atomique. <paramref name="change"/> travaille sur une copie fraîchement relue ;
|
||||
/// s'il lève une exception, rien n'est écrit.
|
||||
/// </summary>
|
||||
public T Update<T>(Func<AdminDatabase, T> change)
|
||||
{
|
||||
// Les lectures ne sont jamais bloquées par une écriture en attente du verrou fichier
|
||||
// (le renommage atomique leur donne l'ancienne ou la nouvelle version, jamais un mélange).
|
||||
lock (_writeLock)
|
||||
{
|
||||
using var fileLock = SecureFiles.AcquireLock(_lockPath, LockTimeout);
|
||||
var database = Load();
|
||||
var result = change(database);
|
||||
SecureFiles.WriteAllTextAtomic(_path, JsonSerializer.Serialize(database, Json), keepBackup: true);
|
||||
lock (_cacheLock)
|
||||
{
|
||||
_cache = database;
|
||||
_cacheStamp = Stamp();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
public void Update(Action<AdminDatabase> change) => Update(db =>
|
||||
{
|
||||
change(db);
|
||||
return true;
|
||||
});
|
||||
|
||||
private AdminDatabase Load()
|
||||
{
|
||||
if (!File.Exists(_path)) return new AdminDatabase();
|
||||
try
|
||||
{
|
||||
var database = JsonSerializer.Deserialize<AdminDatabase>(File.ReadAllText(_path), Json)
|
||||
?? throw new InvalidDataException("fichier vide");
|
||||
if (database.FormatVersion != 1) throw new InvalidDataException($"format {database.FormatVersion} inconnu");
|
||||
database.Admins ??= new();
|
||||
database.Invitations ??= new();
|
||||
foreach (var admin in database.Admins) admin.Keys ??= new();
|
||||
return database;
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or InvalidDataException)
|
||||
{
|
||||
throw new InvalidDataException($"{_path} est illisible ({ex.Message}). Restaurez {FileName}.bak APRÈS l'avoir vérifié : " +
|
||||
"c'est la version précédant la dernière modification (une révocation récente y serait annulée).", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private (DateTime, long) Stamp()
|
||||
{
|
||||
var info = new FileInfo(_path);
|
||||
return info.Exists ? (info.LastWriteTimeUtc, info.Length) : (DateTime.MinValue, -1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Journal d'audit : un fichier JSON Lines par mois (audit/audit-2026-10.jsonl), en ajout seul.
|
||||
/// Chaque action d'administration y figure (réussie ou refusée), avec l'auteur, la clé et l'adresse IP.
|
||||
/// Les entrées sont aussi écrites dans le journal du service (journalctl).
|
||||
/// </summary>
|
||||
public sealed class AuditLog
|
||||
{
|
||||
public const int MaxPageSize = 200;
|
||||
private const int MaxFieldLength = 500;
|
||||
|
||||
/// <summary>
|
||||
/// Échecs SANS authentification (connexion refusée, code d'invitation faux) détaillés au plus
|
||||
/// ce nombre de fois par minute ; au-delà, ils sont comptés et résumés en une ligne : une
|
||||
/// avalanche de tentatives ne peut ni noyer les vraies entrées ni remplir le disque.
|
||||
/// </summary>
|
||||
public const int MaxAnonymousFailuresPerMinute = 10;
|
||||
|
||||
private readonly object _throttleLock = new();
|
||||
private DateTimeOffset _throttleWindow;
|
||||
private int _throttleCount;
|
||||
private int _suppressed;
|
||||
|
||||
private static readonly JsonSerializerOptions Json = new(Contracts.ApiJson.Options) { WriteIndented = false };
|
||||
|
||||
private readonly string _directory;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly ILogger<AuditLog>? _logger;
|
||||
private readonly object _writeLock = new();
|
||||
|
||||
public AuditLog(IOptions<LauncherApiOptions> options, TimeProvider time, ILogger<AuditLog> logger)
|
||||
: this(options.Value.DataDirectoryFullPath, time, logger) { }
|
||||
|
||||
public AuditLog(string dataDirectory, TimeProvider time, ILogger<AuditLog>? logger = null)
|
||||
{
|
||||
_directory = Path.Combine(dataDirectory, "audit");
|
||||
_time = time;
|
||||
_logger = logger;
|
||||
SecureFiles.EnsureDirectory(dataDirectory);
|
||||
SecureFiles.EnsureDirectory(_directory);
|
||||
}
|
||||
|
||||
public void Write(string action, string result, Guid? actorId = null, string? actorName = null, string? keyId = null,
|
||||
string? ip = null, string? target = null, string? details = null)
|
||||
{
|
||||
var entry = new AuditEntry
|
||||
{
|
||||
Timestamp = _time.GetUtcNow(),
|
||||
Action = Clean(action) ?? "?",
|
||||
Result = Clean(result) ?? "?",
|
||||
ActorId = actorId,
|
||||
ActorName = Clean(actorName),
|
||||
KeyId = Clean(keyId),
|
||||
Ip = Clean(ip),
|
||||
Target = Clean(target),
|
||||
Details = Clean(details)
|
||||
};
|
||||
|
||||
_logger?.LogInformation("AUDIT {Action} {Result} acteur={Actor} clé={KeyId} ip={Ip} cible={Target} {Details}",
|
||||
entry.Action, entry.Result, entry.ActorName ?? entry.ActorId?.ToString(), entry.KeyId, entry.Ip, entry.Target, entry.Details);
|
||||
|
||||
var line = JsonSerializer.Serialize(entry, Json) + "\n";
|
||||
var path = Path.Combine(_directory, $"audit-{entry.Timestamp.UtcDateTime:yyyy-MM}.jsonl");
|
||||
try
|
||||
{
|
||||
lock (_writeLock)
|
||||
{
|
||||
using var fileLock = SecureFiles.AcquireLock(Path.Combine(_directory, "audit.lock"), TimeSpan.FromSeconds(5));
|
||||
using var stream = SecureFiles.OpenAppend(path);
|
||||
stream.Write(Encoding.UTF8.GetBytes(line));
|
||||
stream.Flush(flushToDisk: true);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or StoreBusyException)
|
||||
{
|
||||
// L'action elle-même n'est pas annulée, mais l'échec est visible dans journalctl.
|
||||
if (_logger is not null) _logger.LogError(ex, "Écriture du journal d'audit impossible : {Line}", line.TrimEnd());
|
||||
else Console.Error.WriteLine($"ATTENTION : journal d'audit non écrit ({ex.Message}).");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Échec d'une requête non authentifiée (voir <see cref="MaxAnonymousFailuresPerMinute"/>).</summary>
|
||||
public void WriteAnonymousFailure(string action, string? keyId, string? ip, string? details)
|
||||
{
|
||||
int suppressed;
|
||||
lock (_throttleLock)
|
||||
{
|
||||
var now = _time.GetUtcNow();
|
||||
if (now - _throttleWindow >= TimeSpan.FromMinutes(1))
|
||||
{
|
||||
_throttleWindow = now;
|
||||
_throttleCount = 0;
|
||||
}
|
||||
if (_throttleCount >= MaxAnonymousFailuresPerMinute)
|
||||
{
|
||||
if (_suppressed++ == 0)
|
||||
_logger?.LogWarning("AUDIT afflux de tentatives refusées sans authentification (dernière : {Action} ip={Ip}) : " +
|
||||
"détail suspendu, résumé à la minute suivante.", action, ip);
|
||||
return;
|
||||
}
|
||||
_throttleCount++;
|
||||
suppressed = _suppressed;
|
||||
_suppressed = 0;
|
||||
}
|
||||
if (suppressed > 0)
|
||||
Write("audit.suppressed", Contracts.Dtos.AuditResults.Denied,
|
||||
details: $"{suppressed} tentative(s) refusée(s) sans authentification non détaillée(s) (afflux de requêtes)");
|
||||
Write(action, Contracts.Dtos.AuditResults.Denied, keyId: keyId, ip: ip, details: details);
|
||||
}
|
||||
|
||||
private readonly Dictionary<string, (DateTimeOffset Window, int Suppressed)> _perKey = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Refus d'une requête correctement SIGNÉE (clé révoquée ou en attente, rejeu) : toujours
|
||||
/// détaillé, au plus une ligne par clé et par minute (les suivantes sont comptées et signalées
|
||||
/// sur la ligne suivante). Seules les clés enregistrées produisent des signatures valides : la
|
||||
/// table reste petite.
|
||||
/// </summary>
|
||||
public void WriteSignedRefusal(string action, Guid? actorId, string? actorName, string keyId, string? ip, string details)
|
||||
{
|
||||
int suppressed;
|
||||
lock (_throttleLock)
|
||||
{
|
||||
var now = _time.GetUtcNow();
|
||||
if (_perKey.TryGetValue(keyId, out var state) && now - state.Window < TimeSpan.FromMinutes(1))
|
||||
{
|
||||
_perKey[keyId] = (state.Window, state.Suppressed + 1);
|
||||
return;
|
||||
}
|
||||
suppressed = state.Suppressed;
|
||||
_perKey[keyId] = (now, 0);
|
||||
if (_perKey.Count > 10_000) _perKey.Clear();
|
||||
}
|
||||
Write(action, Contracts.Dtos.AuditResults.Denied, actorId, actorName, keyId, ip,
|
||||
details: suppressed > 0 ? $"{details} (+{suppressed} refus identique(s) la minute précédente)" : details);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entrées les plus récentes d'abord. <paramref name="cursor"/> : valeur NextCursor de la page
|
||||
/// précédente (« aaaa-MM:position »). Les fichiers sont lus depuis la fin, par blocs : la mémoire
|
||||
/// utilisée ne dépend pas de leur taille.
|
||||
/// </summary>
|
||||
public AuditPage Read(int limit, string? cursor)
|
||||
{
|
||||
limit = Math.Clamp(limit, 1, MaxPageSize);
|
||||
var months = Directory.Exists(_directory)
|
||||
? Directory.GetFiles(_directory, "audit-*.jsonl").Select(Path.GetFileName).OfType<string>()
|
||||
.Select(name => name["audit-".Length..^".jsonl".Length])
|
||||
.Where(month => DateTime.TryParseExact(month, "yyyy-MM", CultureInfo.InvariantCulture, DateTimeStyles.None, out _))
|
||||
.OrderByDescending(month => month, StringComparer.Ordinal).ToList()
|
||||
: new List<string>();
|
||||
|
||||
string? startMonth = null;
|
||||
var startOffset = long.MaxValue;
|
||||
if (!string.IsNullOrEmpty(cursor))
|
||||
{
|
||||
var parts = cursor.Split(':');
|
||||
if (parts.Length != 2 || !months.Contains(parts[0]) ||
|
||||
!long.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out startOffset))
|
||||
return new AuditPage();
|
||||
startMonth = parts[0];
|
||||
}
|
||||
|
||||
var entries = new List<AuditEntry>(limit);
|
||||
for (var m = 0; m < months.Count; m++)
|
||||
{
|
||||
var month = months[m];
|
||||
if (startMonth is not null && string.CompareOrdinal(month, startMonth) > 0) continue;
|
||||
var end = month == startMonth ? startOffset : long.MaxValue;
|
||||
// Lignes illisibles (écriture interrompue, modification manuelle) ignorées sans rien masquer :
|
||||
// la lecture continue jusqu'à obtenir « limit » entrées lisibles.
|
||||
foreach (var (start, line) in ReadBackward(Path.Combine(_directory, $"audit-{month}.jsonl"), end))
|
||||
{
|
||||
if (TryParse(line) is not { } entry) continue;
|
||||
entries.Add(entry);
|
||||
if (entries.Count == limit)
|
||||
{
|
||||
var more = start > 0 || m < months.Count - 1;
|
||||
return new AuditPage { Entries = entries, NextCursor = more ? $"{month}:{start}" : null };
|
||||
}
|
||||
}
|
||||
}
|
||||
return new AuditPage { Entries = entries };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lignes situées avant <paramref name="end"/> (octets), de la dernière à la première, avec leur
|
||||
/// position, lues par blocs depuis la fin. Une ligne démesurée (plus de 1 Mo) est sautée.
|
||||
/// </summary>
|
||||
private static IEnumerable<(long Start, string Line)> ReadBackward(string path, long end)
|
||||
{
|
||||
if (!File.Exists(path)) yield break;
|
||||
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
var position = Math.Min(end, stream.Length);
|
||||
var carry = Array.Empty<byte>();
|
||||
var skipping = false; // fin de ligne démesurée abandonnée : on cherche son début
|
||||
const int blockSize = 64 * 1024;
|
||||
const int maxLine = 1024 * 1024;
|
||||
|
||||
while (position > 0)
|
||||
{
|
||||
var size = (int)Math.Min(blockSize, position);
|
||||
position -= size;
|
||||
var data = new byte[size + carry.Length];
|
||||
stream.Position = position;
|
||||
stream.ReadExactly(data, 0, size);
|
||||
carry.CopyTo(data, size);
|
||||
|
||||
var lineEnd = data.Length;
|
||||
for (var i = data.Length - 1; i >= 0; i--)
|
||||
{
|
||||
if (data[i] != (byte)'\n') continue;
|
||||
if (!skipping && lineEnd > i + 1) yield return (position + i + 1, Encoding.UTF8.GetString(data, i + 1, lineEnd - i - 1));
|
||||
skipping = false;
|
||||
lineEnd = i;
|
||||
}
|
||||
carry = data[..lineEnd];
|
||||
if (carry.Length > maxLine)
|
||||
{
|
||||
carry = Array.Empty<byte>();
|
||||
skipping = true;
|
||||
}
|
||||
}
|
||||
if (!skipping && carry.Length > 0) yield return (0, Encoding.UTF8.GetString(carry));
|
||||
}
|
||||
|
||||
private static AuditEntry? TryParse(string line)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<AuditEntry>(line, Json);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? Clean(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var sb = new StringBuilder(Math.Min(value.Length, MaxFieldLength));
|
||||
foreach (var c in value)
|
||||
{
|
||||
if (sb.Length >= MaxFieldLength) break;
|
||||
sb.Append(char.IsControl(c) ? ' ' : c);
|
||||
}
|
||||
return sb.ToString().Trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using System.Text;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Fichiers privés du serveur : dossier 0700 et fichiers 0600 sous Linux (le service tourne sous un
|
||||
/// compte dédié), écriture atomique (fichier temporaire puis renommage) et verrou inter-processus
|
||||
/// (le service et l'outil en ligne de commande peuvent écrire en même temps).
|
||||
/// </summary>
|
||||
internal static class SecureFiles
|
||||
{
|
||||
private const UnixFileMode DirectoryMode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute;
|
||||
private const UnixFileMode FileMode600 = UnixFileMode.UserRead | UnixFileMode.UserWrite;
|
||||
|
||||
public static void EnsureDirectory(string path)
|
||||
{
|
||||
if (Directory.Exists(path)) return;
|
||||
if (OperatingSystem.IsWindows()) Directory.CreateDirectory(path);
|
||||
else Directory.CreateDirectory(path, DirectoryMode);
|
||||
}
|
||||
|
||||
/// <summary>Remplace le contenu d'un fichier de façon atomique ; l'ancienne version est copiée en « .bak ».</summary>
|
||||
public static void WriteAllTextAtomic(string path, string content, bool keepBackup)
|
||||
{
|
||||
var temp = path + ".tmp";
|
||||
var bytes = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(content);
|
||||
using (var stream = new FileStream(temp, CreateOptions(System.IO.FileMode.Create)))
|
||||
{
|
||||
stream.Write(bytes);
|
||||
stream.Flush(flushToDisk: true);
|
||||
}
|
||||
if (keepBackup && File.Exists(path)) File.Copy(path, path + ".bak", overwrite: true);
|
||||
File.Move(temp, path, overwrite: true); // renommage : atomique sur le même système de fichiers
|
||||
}
|
||||
|
||||
/// <summary>Crée un fichier uniquement s'il n'existe pas encore (faux s'il existait).</summary>
|
||||
public static bool TryCreateNew(string path, string content)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = new FileStream(path, CreateOptions(System.IO.FileMode.CreateNew));
|
||||
stream.Write(Encoding.UTF8.GetBytes(content));
|
||||
stream.Flush(flushToDisk: true);
|
||||
return true;
|
||||
}
|
||||
catch (IOException) when (File.Exists(path))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Ouvre un fichier en ajout (journal d'audit), créé en 0600.</summary>
|
||||
public static FileStream OpenAppend(string path) =>
|
||||
new(path, Private(new FileStreamOptions { Mode = System.IO.FileMode.Append, Access = FileAccess.Write, Share = FileShare.Read }));
|
||||
|
||||
/// <summary>
|
||||
/// Verrou exclusif inter-processus (fichier « .lock »). Réessaie pendant <paramref name="timeout"/>,
|
||||
/// puis lève <see cref="StoreBusyException"/>.
|
||||
/// </summary>
|
||||
public static FileStream AcquireLock(string lockPath, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (true)
|
||||
{
|
||||
try
|
||||
{
|
||||
return new FileStream(lockPath, Private(new FileStreamOptions
|
||||
{
|
||||
Mode = System.IO.FileMode.OpenOrCreate,
|
||||
Access = FileAccess.ReadWrite,
|
||||
Share = FileShare.None
|
||||
}));
|
||||
}
|
||||
catch (IOException) when (DateTime.UtcNow < deadline)
|
||||
{
|
||||
Thread.Sleep(25);
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
throw new StoreBusyException($"Fichier verrouillé : {Path.GetFileName(lockPath)}", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static FileStreamOptions CreateOptions(System.IO.FileMode mode) =>
|
||||
Private(new FileStreamOptions { Mode = mode, Access = FileAccess.Write, Share = FileShare.None });
|
||||
|
||||
/// <summary>Fichier créé en 0600 sous Linux (lecture/écriture pour le compte du service uniquement).</summary>
|
||||
private static FileStreamOptions Private(FileStreamOptions options)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows()) options.UnixCreateMode = FileMode600;
|
||||
return options;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Données momentanément inaccessibles (verrou tenu par un autre processus) : réponse 503.</summary>
|
||||
public sealed class StoreBusyException : Exception
|
||||
{
|
||||
public StoreBusyException(string message, Exception? inner = null) : base(message, inner) { }
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Security.Cryptography;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Identifiant aléatoire et permanent de ce serveur (fichier « server-id »), inclus dans les messages
|
||||
/// signés : une signature destinée à un autre serveur (test, ancien VPS…) est refusée.
|
||||
/// </summary>
|
||||
public sealed class ServerIdentity
|
||||
{
|
||||
public const string FileName = "server-id";
|
||||
|
||||
public ServerIdentity(IOptions<LauncherApiOptions> options) : this(options.Value) { }
|
||||
|
||||
public ServerIdentity(LauncherApiOptions options)
|
||||
{
|
||||
Origin = options.NormalizedOrigin;
|
||||
ServerId = LoadOrCreate(options.DataDirectoryFullPath);
|
||||
}
|
||||
|
||||
public string ServerId { get; }
|
||||
|
||||
/// <summary>Adresse publique de l'API (« https://hote »).</summary>
|
||||
public string Origin { get; }
|
||||
|
||||
public static string LoadOrCreate(string dataDirectory)
|
||||
{
|
||||
SecureFiles.EnsureDirectory(dataDirectory);
|
||||
var path = Path.Combine(dataDirectory, FileName);
|
||||
if (!File.Exists(path))
|
||||
SecureFiles.TryCreateNew(path, Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant());
|
||||
|
||||
var id = File.ReadAllText(path).Trim();
|
||||
if (id.Length != 32 || !id.All(Uri.IsHexDigit))
|
||||
throw new InvalidDataException($"{path} : identifiant de serveur invalide.");
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
// Développement (F5 dans Visual Studio) : HTTP local, données dans le dossier de compilation.
|
||||
"LauncherApi": {
|
||||
"PublicOrigin": "http://127.0.0.1:5080",
|
||||
"DataDirectory": "data-dev",
|
||||
"RequireHttps": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
|
||||
// Écoute locale uniquement : seul nginx (même machine) joint l'API.
|
||||
"Urls": "http://127.0.0.1:5080",
|
||||
|
||||
"LauncherApi": {
|
||||
// Adresse publique de l'API, SANS chemin : elle est incluse dans les messages signés.
|
||||
"PublicOrigin": "https://launcher.lanisys.fr",
|
||||
// Données privées (administrateurs, audit) : JAMAIS dans le dossier servi par nginx.
|
||||
"DataDirectory": "/var/lib/launcher-api",
|
||||
"RequireHttps": true,
|
||||
"TrustedProxies": [ "127.0.0.1", "::1" ],
|
||||
"Sessions": {
|
||||
"IdleMinutes": 20,
|
||||
"MaxHours": 8,
|
||||
"MaxPerAdmin": 5
|
||||
},
|
||||
"ChallengeLifetimeSeconds": 60,
|
||||
"InvitationLifetimeHours": 24,
|
||||
"Limits": {
|
||||
"RequestsPerMinute": 300,
|
||||
"AuthPerMinute": 20,
|
||||
"EnrollmentsPerHour": 10,
|
||||
"MaxActiveInvitations": 20,
|
||||
"MaxRequestBodyBytes": 65536
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
# Launcher API — installation sur le VPS
|
||||
|
||||
La Launcher API est le serveur d'administration du launcher (voir `docs/ADMIN-ARCHITECTURE.md`).
|
||||
Elle tourne sur le VPS de téléchargement, derrière nginx, à l'adresse
|
||||
`https://launcher.lanisys.fr/api/`. Ce guide suppose le VPS déjà préparé avec
|
||||
« Serveur de téléchargement Exovival – VPS Debian 13 » (compte `admin` en SSH, compte `deploy`,
|
||||
nginx + HTTPS).
|
||||
|
||||
Compter 20 minutes la première fois. Les commandes `PS>` se tapent dans PowerShell sur ton PC,
|
||||
les autres sur le VPS.
|
||||
|
||||
## 1. Publier le serveur (PC)
|
||||
|
||||
Double-clique `tools\publish-server.bat`. Il produit dans `Publish\Server\` :
|
||||
|
||||
* `launcher-api.tar.gz` : le serveur pour Linux x64, **autonome** (aucun .NET à installer sur le VPS) ;
|
||||
* `installer-api.sh` : le script d'installation ;
|
||||
* `launcher-api.tar.gz.sha256` : l'empreinte de l'archive, aussi affichée à la fin du script.
|
||||
|
||||
## 2. Envoyer et installer (PC puis VPS)
|
||||
|
||||
```
|
||||
PS> scp "Publish\Server\launcher-api.tar.gz" "Publish\Server\installer-api.sh" admin@launcher.lanisys.fr:/tmp/
|
||||
```
|
||||
|
||||
```
|
||||
sudo sh /tmp/installer-api.sh /tmp/launcher-api.tar.gz <empreinte affichée par publish-server.bat>
|
||||
rm /tmp/launcher-api.tar.gz /tmp/installer-api.sh
|
||||
```
|
||||
|
||||
L'empreinte garantit que l'archive installée (en root) est exactement celle produite sur ton PC :
|
||||
une archive altérée pendant le transfert ou dans `/tmp` est refusée sans rien modifier.
|
||||
|
||||
Le script installe les binaires dans `/opt/launcher-api` (propriétaire root), le service systemd
|
||||
`launcher-api` (compte `deploy`, durci, aucune connexion sortante), le démarre et vérifie qu'il répond. Il doit finir par
|
||||
`{"status":"ok"}`. Les données privées sont dans `/var/lib/launcher-api` (droits 700).
|
||||
|
||||
> Réglages propres au VPS (autre domaine, durées de session…) : crée
|
||||
> `/opt/launcher-api/appsettings.Production.json` avec uniquement les valeurs à changer, par ex.
|
||||
> `{ "LauncherApi": { "PublicOrigin": "https://dl.autredomaine.fr" } }`, puis
|
||||
> `sudo systemctl restart launcher-api`. Ce fichier est conservé lors des mises à jour.
|
||||
|
||||
## 3. Brancher nginx (VPS)
|
||||
|
||||
1. Limite de requêtes : ajoute cette ligne dans `/etc/nginx/conf.d/securite.conf`
|
||||
(à côté de `limit_conn_zone`) :
|
||||
|
||||
```
|
||||
limit_req_zone $binary_remote_addr zone=api_par_ip:10m rate=5r/s;
|
||||
```
|
||||
2. Ouvre `/etc/nginx/sites-available/dl-exovival` et colle le bloc `location ^~ /api/ { … }` de
|
||||
`server/deploy/nginx-api.conf` dans le bloc **HTTPS** (`listen 443 ssl`), avant `location / {`.
|
||||
3. Vérifie et recharge :
|
||||
|
||||
```
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
4. Teste depuis ton PC :
|
||||
|
||||
```
|
||||
PS> curl.exe -s https://launcher.lanisys.fr/api/v1/info
|
||||
```
|
||||
|
||||
Réponse attendue : `{"serverId":"…","origin":"https://launcher.lanisys.fr",…}`. Si `origin` est
|
||||
différente de l'adresse utilisée, corrige `PublicOrigin` (étape 2) : sinon toutes les
|
||||
signatures seront refusées.
|
||||
|
||||
## 4. Créer ta clé d'administrateur (PC)
|
||||
|
||||
Double-clique `tools\admin-key.bat`. Choisis une phrase de passe (12 caractères minimum). Il crée :
|
||||
|
||||
| Fichier | Contenu |
|
||||
|---|---|
|
||||
| `%USERPROFILE%\.launcher-keys\admin.key` | Clé **privée**, chiffrée par ta phrase de passe. **Sauvegarde-la** (clé USB, gestionnaire de mots de passe) ; jamais dans Git, jamais sur le serveur. |
|
||||
| `%USERPROFILE%\.launcher-keys\admin.pub` | Clé publique : c'est elle qu'on donne au serveur. |
|
||||
|
||||
Note l'**empreinte** affichée (`AB12 CD34 EF56 7890 1A2B`).
|
||||
|
||||
> Phase 2 : le launcher saura créer la clé lui-même (Windows Hello / TPM) et importer ce fichier.
|
||||
|
||||
## 5. Te déclarer propriétaire (PC puis VPS, une seule fois)
|
||||
|
||||
```
|
||||
PS> scp "$env:USERPROFILE\.launcher-keys\admin.pub" admin@launcher.lanisys.fr:/tmp/owner.pub
|
||||
```
|
||||
|
||||
```
|
||||
sudo -u deploy /opt/launcher-api/GameLauncher.Server add-owner --name "Nicolas" --public-key /tmp/owner.pub --label "PC bureau"
|
||||
rm /tmp/owner.pub
|
||||
```
|
||||
|
||||
**Vérifie que l'empreinte affichée est celle de l'étape 4.** L'accès SSH administrateur est la
|
||||
preuve d'identité : c'est pour cela que le premier propriétaire se crée sur le VPS.
|
||||
|
||||
## 6. Tester la connexion (PC)
|
||||
|
||||
```
|
||||
PS> tools\admin-key.bat login-test --api https://launcher.lanisys.fr/api/
|
||||
```
|
||||
|
||||
Résultat attendu : `Connexion réussie : Nicolas (Propriétaire)` et la liste des permissions.
|
||||
|
||||
## 7. Ajouter un administrateur
|
||||
|
||||
En attendant le menu d'administration du launcher (phases 2 et 6), avec `tools\admin-key.bat` :
|
||||
|
||||
1. **Toi** : `admin-key.bat invite --api https://launcher.lanisys.fr/api/ --name "Paul" --role editor`
|
||||
(rôles : `owner`, `admin`, `editor`, voir `docs/ADMIN-ARCHITECTURE.md` § 7). Transmets le code affiché (valable 24 h, usage unique).
|
||||
2. **Paul** : `admin-key.bat` (création de sa clé), puis
|
||||
`admin-key.bat enroll --api https://launcher.lanisys.fr/api/ --code <code>` ; il te communique
|
||||
**par téléphone ou message** l'empreinte affichée.
|
||||
3. **Toi** : `admin-key.bat admins --api …` (repère l'identifiant de Paul et de sa clé), puis
|
||||
`admin-key.bat approve --api … --admin <id> --key-id <clé> --fingerprint "<empreinte dictée par Paul>"`.
|
||||
|
||||
Le serveur n'affiche pas l'empreinte des clés en attente : l'approbation n'est possible qu'avec
|
||||
celle que Paul te donne. Si elle ne correspond pas, **n'insiste pas** : quelqu'un d'autre a pu
|
||||
utiliser le code. Révoque (`revoke-admin`) et recommence.
|
||||
|
||||
Nouveau PC pour un administrateur existant : **lui-même** lance `invite --api … --for <son id>`
|
||||
depuis un PC déjà enregistré, `enroll` sur le nouveau PC, puis `approve` depuis l'ancien. Personne
|
||||
d'autre ne peut approuver une clé à son nom. S'il a perdu tous ses PC : voir § 9 (`add-key`).
|
||||
|
||||
## 8. Mettre à jour le serveur
|
||||
|
||||
Refais les étapes 1 et 2. Les administrateurs, l'audit et `appsettings.Production.json` sont
|
||||
conservés ; les sessions ouvertes sont fermées (reconnexion). Si la nouvelle version ne démarre
|
||||
pas, le script remet la précédente en place.
|
||||
|
||||
## 9. Récupération (VPS)
|
||||
|
||||
| Situation | Commande |
|
||||
|---|---|
|
||||
| Voir les administrateurs, clés et empreintes | `sudo -u deploy /opt/launcher-api/GameLauncher.Server list` |
|
||||
| PC perdu ou volé | `sudo -u deploy /opt/launcher-api/GameLauncher.Server revoke-key --key <identifiant>` |
|
||||
| Retirer un administrateur | `sudo -u deploy /opt/launcher-api/GameLauncher.Server revoke-admin --id <identifiant>` |
|
||||
| Un administrateur a perdu tous ses PC | Il crée une nouvelle clé et t'envoie son `.pub`, puis `sudo -u deploy /opt/launcher-api/GameLauncher.Server add-key --admin <identifiant> --public-key /tmp/paul.pub` (vérifie l'empreinte avec lui) |
|
||||
| Toutes tes clés perdues | Nouvelle clé (étape 4), puis `add-owner` (étape 5) ; révoque ensuite les anciennes clés |
|
||||
|
||||
Toujours `sudo -u deploy …` : lancé en root, l'outil refuse (les fichiers recréés appartiendraient à
|
||||
root et le service ne pourrait plus les lire). Il affiche le dossier de données utilisé ; il lit les
|
||||
mêmes réglages que le service (`appsettings.json` puis `appsettings.Production.json`).
|
||||
|
||||
`admins.json` illisible : le service refuse de démarrer. `admins.json.bak` est la version qui
|
||||
précède la **dernière** modification : vérifie-la avant de la restaurer (si cette dernière
|
||||
modification était une révocation, la restaurer réactiverait la clé révoquée ; refais alors la
|
||||
révocation).
|
||||
|
||||
Le service n'a pas besoin d'être redémarré : une révocation ferme immédiatement les sessions concernées.
|
||||
|
||||
## 10. Surveillance
|
||||
|
||||
| Pour… | Commande |
|
||||
|---|---|
|
||||
| Journal du service (connexions, refus, erreurs) | `journalctl -u launcher-api -f` |
|
||||
| Journal d'audit (une ligne JSON par action) | `sudo tail -n 20 /var/lib/launcher-api/audit/audit-$(date +%Y-%m).jsonl` |
|
||||
| Niveau de durcissement du service | `systemd-analyze security launcher-api` |
|
||||
| État | `systemctl status launcher-api` |
|
||||
|
||||
**Sauvegarde** : `/var/lib/launcher-api/admins.json` (une copie `.bak` de la version précédente est
|
||||
gardée à côté). Il ne contient aucun secret (clés publiques uniquement), mais sa perte oblige à
|
||||
recréer les administrateurs.
|
||||
|
||||
## Fichiers sur le VPS
|
||||
|
||||
```
|
||||
/opt/launcher-api/ binaires (root, lecture seule pour le service)
|
||||
├─ GameLauncher.Server serveur + outil en ligne de commande
|
||||
├─ appsettings.json réglages livrés (remplacés à chaque mise à jour)
|
||||
└─ appsettings.Production.json TES réglages (facultatif, conservé)
|
||||
/var/lib/launcher-api/ données privées (deploy, 700)
|
||||
├─ admins.json (+ .bak) administrateurs, clés publiques, invitations (empreintes)
|
||||
├─ server-id identifiant aléatoire du serveur
|
||||
├─ audit/audit-AAAA-MM.jsonl journal d'audit
|
||||
└─ data-protection/ clés internes d'ASP.NET Core (inutilisées par l'API)
|
||||
/etc/systemd/system/launcher-api.service
|
||||
```
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/bin/sh
|
||||
# =============================================================================
|
||||
# Installe ou met à jour la Launcher API sur le VPS (compte admin, avec sudo) :
|
||||
# sudo sh installer-api.sh /tmp/launcher-api.tar.gz <empreinte SHA-256>
|
||||
# L'empreinte est affichée par tools\publish-server.bat (fichier launcher-api.tar.gz.sha256) :
|
||||
# une archive modifiée pendant le transfert ou dans /tmp est refusée.
|
||||
#
|
||||
# • binaires dans /opt/launcher-api (propriétaire root : le service ne peut pas les modifier) ;
|
||||
# • service systemd « launcher-api » (compte deploy, voir launcher-api.service) ;
|
||||
# • appsettings.Production.json (réglages propres au VPS) conservé d'une version à l'autre ;
|
||||
# • en cas d'échec du démarrage, la version précédente est remise en place.
|
||||
# =============================================================================
|
||||
set -eu
|
||||
|
||||
ARCHIVE="${1:-}"
|
||||
EXPECTED_SHA256=$(printf '%s' "${2:-}" | tr 'A-F' 'a-f')
|
||||
INSTALL_DIR=/opt/launcher-api
|
||||
SERVICE=launcher-api
|
||||
SERVICE_USER=deploy
|
||||
|
||||
fail() { echo "ERREUR : $*" >&2; exit 1; }
|
||||
|
||||
[ "$(id -u)" -eq 0 ] || fail "lancez ce script avec sudo."
|
||||
[ -n "$ARCHIVE" ] && [ -f "$ARCHIVE" ] || fail "utilisation : sudo sh installer-api.sh /tmp/launcher-api.tar.gz <empreinte SHA-256>"
|
||||
printf '%s' "$EXPECTED_SHA256" | grep -Eq '^[0-9a-f]{64}$' || fail "empreinte SHA-256 manquante ou invalide (voir launcher-api.tar.gz.sha256)."
|
||||
id "$SERVICE_USER" >/dev/null 2>&1 || fail "le compte $SERVICE_USER n'existe pas (guide du VPS, étape 9)."
|
||||
command -v curl >/dev/null 2>&1 || fail "curl est nécessaire (sudo apt install -y curl)."
|
||||
|
||||
TMPROOT=$(mktemp -d)
|
||||
trap 'rm -rf "$TMPROOT"' EXIT
|
||||
# Copie privée de l'archive (dossier 700), PUIS vérification : plus rien ne peut la modifier entre-temps.
|
||||
cp "$ARCHIVE" "$TMPROOT/archive.tar.gz"
|
||||
ACTUAL_SHA256=$(sha256sum "$TMPROOT/archive.tar.gz" | cut -d' ' -f1)
|
||||
[ "$ACTUAL_SHA256" = "$EXPECTED_SHA256" ] || fail "empreinte différente : archive altérée ou incomplète. Rien n'a été modifié."
|
||||
WORK="$TMPROOT/files"
|
||||
mkdir "$WORK"
|
||||
tar -xzf "$TMPROOT/archive.tar.gz" -C "$WORK" --no-same-owner
|
||||
[ -f "$WORK/GameLauncher.Server" ] || fail "archive invalide : GameLauncher.Server absent."
|
||||
[ -f "$WORK/deploy/launcher-api.service" ] || fail "archive invalide : deploy/launcher-api.service absent."
|
||||
|
||||
echo "== Préparation de la nouvelle version"
|
||||
rm -rf "$INSTALL_DIR.new"
|
||||
mkdir -p "$INSTALL_DIR.new"
|
||||
cp -a "$WORK/." "$INSTALL_DIR.new/"
|
||||
rm -rf "$INSTALL_DIR.new/deploy"
|
||||
if [ -f "$INSTALL_DIR/appsettings.Production.json" ]; then
|
||||
cp -a "$INSTALL_DIR/appsettings.Production.json" "$INSTALL_DIR.new/"
|
||||
fi
|
||||
chown -R root:root "$INSTALL_DIR.new"
|
||||
find "$INSTALL_DIR.new" -type d -exec chmod 755 {} +
|
||||
find "$INSTALL_DIR.new" -type f -exec chmod 644 {} +
|
||||
chmod 755 "$INSTALL_DIR.new/GameLauncher.Server"
|
||||
|
||||
echo "== Installation du service"
|
||||
UNIT=/etc/systemd/system/$SERVICE.service
|
||||
rm -f "$UNIT.old"
|
||||
[ -f "$UNIT" ] && cp -a "$UNIT" "$UNIT.old"
|
||||
systemctl stop $SERVICE 2>/dev/null || true
|
||||
install -m 644 -o root -g root "$WORK/deploy/launcher-api.service" "$UNIT"
|
||||
systemctl daemon-reload
|
||||
|
||||
rm -rf "$INSTALL_DIR.old"
|
||||
[ -d "$INSTALL_DIR" ] && mv "$INSTALL_DIR" "$INSTALL_DIR.old"
|
||||
mv "$INSTALL_DIR.new" "$INSTALL_DIR"
|
||||
systemctl enable --now $SERVICE
|
||||
|
||||
echo "== Vérification"
|
||||
OK=0
|
||||
for i in 1 2 3 4 5 6 7 8 9 10; do
|
||||
if curl -fsS http://127.0.0.1:5080/api/v1/health >/dev/null 2>&1; then OK=1; break; fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [ "$OK" -ne 1 ]; then
|
||||
echo "La nouvelle version ne répond pas. Dernières lignes du journal :" >&2
|
||||
journalctl -u $SERVICE -n 30 --no-pager >&2 || true
|
||||
if [ -d "$INSTALL_DIR.old" ]; then
|
||||
echo "Retour à la version précédente." >&2
|
||||
systemctl stop $SERVICE || true
|
||||
rm -rf "$INSTALL_DIR.failed"
|
||||
mv "$INSTALL_DIR" "$INSTALL_DIR.failed"
|
||||
mv "$INSTALL_DIR.old" "$INSTALL_DIR"
|
||||
[ -f "$UNIT.old" ] && cp -a "$UNIT.old" "$UNIT"
|
||||
systemctl daemon-reload
|
||||
systemctl start $SERVICE || true
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Launcher API opérationnelle :"
|
||||
curl -fsS http://127.0.0.1:5080/api/v1/health; echo
|
||||
echo "Journal : journalctl -u $SERVICE -f"
|
||||
echo "Premier propriétaire (une seule fois) : voir LISEZMOI.md, étape 5."
|
||||
@@ -0,0 +1,72 @@
|
||||
# =============================================================================
|
||||
# Launcher API : service systemd (installé par installer-api.sh).
|
||||
# /etc/systemd/system/launcher-api.service
|
||||
#
|
||||
# Le service tourne sous le compte « deploy » (déjà propriétaire des fichiers du jeu, sans
|
||||
# terminal), enfermé par systemd : système en lecture seule, pas d'accès à /home, aucun privilège.
|
||||
# Ses binaires (/opt/launcher-api) appartiennent à root : il ne peut pas se modifier lui-même.
|
||||
# Données privées : /var/lib/launcher-api (créé par systemd, droits 700).
|
||||
#
|
||||
# Vérifier le durcissement : systemd-analyze security launcher-api
|
||||
# Journal : journalctl -u launcher-api -f
|
||||
# =============================================================================
|
||||
[Unit]
|
||||
Description=Launcher API (administration du launcher)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=deploy
|
||||
Group=deploy
|
||||
WorkingDirectory=/opt/launcher-api
|
||||
ExecStart=/opt/launcher-api/GameLauncher.Server
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
TimeoutStopSec=20
|
||||
|
||||
Environment=ASPNETCORE_ENVIRONMENT=Production
|
||||
Environment=DOTNET_CLI_TELEMETRY_OPTOUT=1
|
||||
Environment=DOTNET_NOLOGO=1
|
||||
|
||||
# Données privées : /var/lib/launcher-api (administrateurs, audit, identifiant du serveur)
|
||||
StateDirectory=launcher-api
|
||||
StateDirectoryMode=0700
|
||||
UMask=0077
|
||||
|
||||
# --- Durcissement ------------------------------------------------------------
|
||||
NoNewPrivileges=yes
|
||||
CapabilityBoundingSet=
|
||||
AmbientCapabilities=
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
PrivateDevices=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectKernelLogs=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectClock=yes
|
||||
ProtectHostname=yes
|
||||
ProtectProc=invisible
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
||||
# Réseau : uniquement la machine elle-même (nginx). Aucune connexion sortante possible, même en cas de compromission.
|
||||
IPAddressDeny=any
|
||||
IPAddressAllow=localhost
|
||||
RestrictNamespaces=yes
|
||||
RestrictRealtime=yes
|
||||
RestrictSUIDSGID=yes
|
||||
LockPersonality=yes
|
||||
RemoveIPC=yes
|
||||
SystemCallArchitectures=native
|
||||
# MemoryDenyWriteExecute n'est PAS activé : le compilateur JIT de .NET a besoin de mémoire exécutable.
|
||||
# SystemCallFilter n'est pas activé : .NET utilise des appels système variés selon les versions
|
||||
# (un filtre trop strict provoquerait des arrêts difficiles à diagnostiquer).
|
||||
|
||||
# --- Ressources (petit VPS) ------------------------------------------------------
|
||||
MemoryMax=512M
|
||||
TasksMax=256
|
||||
LimitNOFILE=8192
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,34 @@
|
||||
# =============================================================================
|
||||
# Launcher API derrière nginx — DEUX ajouts (voir LISEZMOI.md, étape 3) :
|
||||
#
|
||||
# 1) Dans /etc/nginx/conf.d/securite.conf (contexte « http », à côté de limit_conn_zone) :
|
||||
#
|
||||
# limit_req_zone $binary_remote_addr zone=api_par_ip:10m rate=5r/s;
|
||||
#
|
||||
# 2) Dans le bloc HTTPS (port 443, créé par certbot) du site
|
||||
# /etc/nginx/sites-available/dl-exovival, AVANT « location / { … } » :
|
||||
# (le bloc ci-dessous)
|
||||
#
|
||||
# Puis : sudo nginx -t && sudo systemctl reload nginx
|
||||
# =============================================================================
|
||||
|
||||
# « ^~ » : aucune autre règle (manifest, objects, .zip…) ne s'applique sous /api/.
|
||||
location ^~ /api/ {
|
||||
limit_req zone=api_par_ip burst=20 nodelay;
|
||||
limit_req_status 429;
|
||||
|
||||
# Requêtes d'administration : petites. (La publication de versions, en phase 5, aura sa propre règle.)
|
||||
client_max_body_size 64k;
|
||||
|
||||
proxy_pass http://127.0.0.1:5080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
# REMPLACE l'en-tête (au lieu d'ajouter à celui du client) : l'adresse IP transmise ne peut pas être falsifiée.
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Security.Cryptography;
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.ApiClient;
|
||||
|
||||
/// <summary>
|
||||
/// Clé d'administrateur dans un fichier chiffré par une phrase de passe : PKCS#8 chiffré (AES-256,
|
||||
/// PBKDF2-SHA256, 600 000 itérations), comme la clé de signature des mises à jour du launcher.
|
||||
/// Le fichier seul ne sert à rien sans la phrase de passe.
|
||||
/// </summary>
|
||||
public static class AdminKeyFile
|
||||
{
|
||||
public const int MinPassphraseLength = 12;
|
||||
public const string PrivateKeyExtension = ".key";
|
||||
public const string PublicKeyExtension = ".pub";
|
||||
|
||||
private static readonly PbeParameters Encryption = new(PbeEncryptionAlgorithm.Aes256Cbc, HashAlgorithmName.SHA256, 600_000);
|
||||
|
||||
public static ECDsa Generate() => ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
|
||||
/// <summary>
|
||||
/// Enregistre la clé chiffrée (refuse d'écraser un fichier existant) et sa clé publique à côté
|
||||
/// (même nom, extension .pub). Renvoie le chemin de la clé publique.
|
||||
/// </summary>
|
||||
public static string Save(ECDsa key, string path, ReadOnlySpan<char> passphrase)
|
||||
{
|
||||
if (passphrase.Length < MinPassphraseLength)
|
||||
throw new ArgumentException($"La phrase de passe doit contenir au moins {MinPassphraseLength} caractères.");
|
||||
if (File.Exists(path)) throw new IOException($"Le fichier existe déjà : {path}");
|
||||
|
||||
var directory = Path.GetDirectoryName(Path.GetFullPath(path));
|
||||
if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory);
|
||||
|
||||
var pem = key.ExportEncryptedPkcs8PrivateKeyPem(passphrase, Encryption);
|
||||
using (var stream = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None))
|
||||
using (var writer = new StreamWriter(stream))
|
||||
writer.Write(pem);
|
||||
|
||||
var publicPath = Path.ChangeExtension(path, PublicKeyExtension);
|
||||
File.WriteAllText(publicPath, AdminKeys.ToPem(key.ExportSubjectPublicKeyInfo()));
|
||||
return publicPath;
|
||||
}
|
||||
|
||||
/// <summary>Déchiffre la clé. <see cref="CryptographicException"/> si la phrase de passe est fausse.</summary>
|
||||
public static ECDsa Load(string path, ReadOnlySpan<char> passphrase)
|
||||
{
|
||||
var info = new FileInfo(path);
|
||||
if (!info.Exists) throw new FileNotFoundException($"Clé introuvable : {path}", path);
|
||||
if (info.Length > 16 * 1024) throw new CryptographicException("Fichier de clé inattendu (trop volumineux).");
|
||||
|
||||
var pem = File.ReadAllText(path);
|
||||
if (!pem.Contains("-----BEGIN ENCRYPTED PRIVATE KEY-----", StringComparison.Ordinal))
|
||||
throw new CryptographicException("Ce fichier n'est pas une clé privée chiffrée.");
|
||||
|
||||
var key = ECDsa.Create();
|
||||
try
|
||||
{
|
||||
key.ImportFromEncryptedPem(pem, passphrase);
|
||||
}
|
||||
catch (Exception ex) when (ex is CryptographicException or ArgumentException)
|
||||
{
|
||||
key.Dispose();
|
||||
throw new CryptographicException("Phrase de passe incorrecte (ou fichier de clé endommagé).", ex);
|
||||
}
|
||||
try
|
||||
{
|
||||
AdminKeys.ParsePublicKey(Convert.ToBase64String(key.ExportSubjectPublicKeyInfo())); // clé P-256 uniquement
|
||||
return key;
|
||||
}
|
||||
catch
|
||||
{
|
||||
key.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
using System.Security.Cryptography;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.ApiClient;
|
||||
|
||||
/// <summary>
|
||||
/// Session d'administration ouverte. Le jeton reste en mémoire (jamais écrit sur le disque) ;
|
||||
/// chaque requête qui modifie quelque chose est signée par la clé (<see cref="SignedMessages.Request"/>).
|
||||
/// Les modules du launcher ajoutent leurs propres appels avec <see cref="GetAsync{T}"/> et
|
||||
/// <see cref="SendSignedAsync{T}"/>.
|
||||
/// </summary>
|
||||
public sealed class AdminSession : IAsyncDisposable
|
||||
{
|
||||
private readonly LauncherApiClient _client;
|
||||
private readonly IAdminSigner _signer;
|
||||
private readonly TimeSpan _clockOffset;
|
||||
private string? _token;
|
||||
|
||||
internal AdminSession(LauncherApiClient client, IAdminSigner signer, string serverId, LoginResponse login, TimeSpan clockOffset)
|
||||
{
|
||||
_client = client;
|
||||
_signer = signer;
|
||||
_clockOffset = clockOffset;
|
||||
_token = login.Token;
|
||||
ServerId = serverId;
|
||||
SessionId = login.SessionId;
|
||||
Profile = login.Admin;
|
||||
ExpiresAt = login.ExpiresAt;
|
||||
IdleExpiresAt = login.IdleExpiresAt;
|
||||
}
|
||||
|
||||
public string ServerId { get; }
|
||||
public string SessionId { get; }
|
||||
public AdminProfile Profile { get; private set; }
|
||||
public DateTimeOffset ExpiresAt { get; }
|
||||
public DateTimeOffset IdleExpiresAt { get; private set; }
|
||||
public bool IsOpen => _token is not null;
|
||||
|
||||
public bool Has(string permission) => Profile.Permissions.Contains(permission, StringComparer.Ordinal);
|
||||
|
||||
// ------------------------------------------------------------------ Session
|
||||
|
||||
/// <summary>Relit la session (droits à jour) et la prolonge.</summary>
|
||||
public async Task<SessionInfo> RefreshAsync(CancellationToken ct = default)
|
||||
{
|
||||
var info = await GetAsync<SessionInfo>(ApiRoutes.AuthMe, ct).ConfigureAwait(false);
|
||||
Profile = info.Admin;
|
||||
IdleExpiresAt = info.IdleExpiresAt;
|
||||
return info;
|
||||
}
|
||||
|
||||
/// <summary>Ferme la session sur le serveur (sans erreur si elle avait déjà expiré).</summary>
|
||||
public async Task LogoutAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (_token is null) return;
|
||||
try
|
||||
{
|
||||
await SendCoreAsync<NoContent>(HttpMethod.Post, ApiRoutes.AuthLogout, null, signed: false, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (ApiException)
|
||||
{
|
||||
// session déjà expirée, serveur injoignable : le jeton est oublié de toute façon
|
||||
}
|
||||
finally
|
||||
{
|
||||
_token = null;
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync() => await LogoutAsync().ConfigureAwait(false);
|
||||
|
||||
// ------------------------------------------------------------------ Administrateurs
|
||||
|
||||
public Task<List<AdminSummary>> ListAdminsAsync(CancellationToken ct = default) =>
|
||||
GetAsync<List<AdminSummary>>(ApiRoutes.Admins, ct);
|
||||
|
||||
public Task<List<InvitationSummary>> ListInvitationsAsync(CancellationToken ct = default) =>
|
||||
GetAsync<List<InvitationSummary>>(ApiRoutes.Invitations, ct);
|
||||
|
||||
public Task<InvitationCreated> CreateInvitationAsync(CreateInvitationRequest request, CancellationToken ct = default) =>
|
||||
SendSignedAsync<InvitationCreated>(HttpMethod.Post, ApiRoutes.Invitations, request, ct);
|
||||
|
||||
public Task DeleteInvitationAsync(Guid invitationId, CancellationToken ct = default) =>
|
||||
SendSignedAsync<NoContent>(HttpMethod.Delete, ApiRoutes.Invitation(invitationId), (object?)null, ct);
|
||||
|
||||
public Task<AdminSummary> ApproveKeyAsync(Guid adminId, string keyId, string fingerprint, CancellationToken ct = default) =>
|
||||
SendSignedAsync<AdminSummary>(HttpMethod.Post, ApiRoutes.KeyApprove(adminId, keyId), new ApproveKeyRequest { Fingerprint = fingerprint }, ct);
|
||||
|
||||
public Task<AdminSummary> RevokeKeyAsync(Guid adminId, string keyId, CancellationToken ct = default) =>
|
||||
SendSignedAsync<AdminSummary>(HttpMethod.Post, ApiRoutes.KeyRevoke(adminId, keyId), (object?)null, ct);
|
||||
|
||||
public Task<AdminSummary> ChangeRoleAsync(Guid adminId, string role, CancellationToken ct = default) =>
|
||||
SendSignedAsync<AdminSummary>(HttpMethod.Post, ApiRoutes.AdminRole(adminId), new ChangeRoleRequest { Role = role }, ct);
|
||||
|
||||
public Task<AdminSummary> RevokeAdminAsync(Guid adminId, CancellationToken ct = default) =>
|
||||
SendSignedAsync<AdminSummary>(HttpMethod.Post, ApiRoutes.AdminRevoke(adminId), (object?)null, ct);
|
||||
|
||||
public Task<AuditPage> GetAuditAsync(int limit = 50, string? cursor = null, CancellationToken ct = default)
|
||||
{
|
||||
var route = $"{ApiRoutes.Audit}?limit={limit}";
|
||||
if (!string.IsNullOrEmpty(cursor)) route += "&cursor=" + Uri.EscapeDataString(cursor);
|
||||
return GetAsync<AuditPage>(route, ct);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Requêtes génériques (modules)
|
||||
|
||||
public Task<T> GetAsync<T>(string route, CancellationToken ct = default) =>
|
||||
SendCoreAsync<T>(HttpMethod.Get, route, null, signed: false, ct);
|
||||
|
||||
/// <summary>Requête qui modifie : corps JSON (ou aucun corps si null), signée par la clé.</summary>
|
||||
public Task<T> SendSignedAsync<T>(HttpMethod method, string route, object? body, CancellationToken ct = default) =>
|
||||
SendCoreAsync<T>(method, route, body is null ? null : LauncherApiClient.Serialize(body), signed: true, ct);
|
||||
|
||||
private async Task<T> SendCoreAsync<T>(HttpMethod method, string route, byte[]? body, bool signed, CancellationToken ct)
|
||||
{
|
||||
var token = _token ?? throw new ApiException(401, ApiErrorCodes.Unauthorized, "Session fermée : reconnectez-vous.");
|
||||
|
||||
(long Timestamp, string Nonce, byte[] Signature)? signature = null;
|
||||
if (signed)
|
||||
{
|
||||
var timestamp = (DateTimeOffset.UtcNow + _clockOffset).ToUnixTimeSeconds();
|
||||
var nonce = Base64Url.Encode(RandomNumberGenerator.GetBytes(16));
|
||||
var path = _client.Resolve(route).PathAndQuery;
|
||||
var message = SignedMessages.Request(_client.Origin, ServerId, SessionId, method.Method, path,
|
||||
body ?? Array.Empty<byte>(), timestamp, nonce);
|
||||
signature = (timestamp, nonce, await _signer.SignAsync(message, ct).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await _client.SendAsync<T>(method, route, body, request =>
|
||||
{
|
||||
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||
if (signature is { } s)
|
||||
{
|
||||
request.Headers.Add(SignedRequestHeaders.Timestamp, s.Timestamp.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
request.Headers.Add(SignedRequestHeaders.Nonce, s.Nonce);
|
||||
request.Headers.Add(SignedRequestHeaders.Signature, Convert.ToBase64String(s.Signature));
|
||||
}
|
||||
}, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (ApiException ex) when (ex.Status == 401 && ex.Code == ApiErrorCodes.Unauthorized)
|
||||
{
|
||||
_token = null; // session expirée ou révoquée : il faudra se reconnecter
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
|
||||
namespace GameLauncher.ApiClient;
|
||||
|
||||
/// <summary>Erreur renvoyée par la Launcher API (ou problème de communication).</summary>
|
||||
public sealed class ApiException : Exception
|
||||
{
|
||||
public ApiException(int status, string code, string message, Exception? inner = null) : base(message, inner)
|
||||
{
|
||||
Status = status;
|
||||
Code = code;
|
||||
}
|
||||
|
||||
/// <summary>Code HTTP (0 : serveur injoignable).</summary>
|
||||
public int Status { get; }
|
||||
|
||||
/// <summary>Code stable (<see cref="ApiErrorCodes"/>).</summary>
|
||||
public string Code { get; }
|
||||
|
||||
public bool IsUnauthorized => Status == 401;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
Client de la Launcher API (sans WPF) : connexion par défi signé, requêtes signées, appels typés,
|
||||
fichier de clé d'administrateur chiffré. Utilisé par le launcher (module Administration),
|
||||
par tools\AdminKeyTool et par les tests. Voir docs\ADMIN-ARCHITECTURE.md.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RootNamespace>GameLauncher.ApiClient</RootNamespace>
|
||||
<AssemblyName>GameLauncher.ApiClient</AssemblyName>
|
||||
<Platforms>AnyCPU;x64</Platforms>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\GameLauncher.Contracts\GameLauncher.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.Security.Cryptography;
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.ApiClient;
|
||||
|
||||
/// <summary>
|
||||
/// Clé privée d'un administrateur, vue par le client : elle signe, elle ne sort jamais.
|
||||
/// Implémentations : <see cref="EcdsaAdminSigner"/> (clé en mémoire, déverrouillée depuis un fichier
|
||||
/// chiffré) ; Windows Hello / TPM dans le launcher (phase 2).
|
||||
/// </summary>
|
||||
public interface IAdminSigner
|
||||
{
|
||||
/// <summary>Clé publique (SPKI DER).</summary>
|
||||
byte[] PublicKey { get; }
|
||||
|
||||
string KeyId { get; }
|
||||
|
||||
/// <summary>Signature ECDSA P-256/SHA-256 (IEEE P1363, 64 octets). Peut demander une confirmation à l'utilisateur.</summary>
|
||||
ValueTask<byte[]> SignAsync(byte[] message, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>Clé ECDSA P-256 en mémoire.</summary>
|
||||
public sealed class EcdsaAdminSigner : IAdminSigner, IDisposable
|
||||
{
|
||||
private readonly ECDsa _key;
|
||||
|
||||
/// <param name="key">Clé privée P-256 ; elle appartient désormais à ce signataire (libérée par <see cref="Dispose"/>).</param>
|
||||
public EcdsaAdminSigner(ECDsa key)
|
||||
{
|
||||
_key = key;
|
||||
PublicKey = AdminKeys.ParsePublicKey(Convert.ToBase64String(key.ExportSubjectPublicKeyInfo())); // vérifie P-256
|
||||
KeyId = AdminKeys.ComputeKeyId(PublicKey);
|
||||
}
|
||||
|
||||
public byte[] PublicKey { get; }
|
||||
public string KeyId { get; }
|
||||
public string Fingerprint => AdminKeys.ComputeFingerprint(PublicKey);
|
||||
|
||||
public ValueTask<byte[]> SignAsync(byte[] message, CancellationToken ct = default) =>
|
||||
ValueTask.FromResult(AdminKeys.Sign(_key, message));
|
||||
|
||||
public void Dispose() => _key.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.ApiClient;
|
||||
|
||||
/// <summary>
|
||||
/// Client de la Launcher API. L'adresse vient de la configuration INTÉGRÉE à l'exécutable (jamais
|
||||
/// d'un fichier modifiable) ; HTTPS obligatoire (HTTP accepté uniquement en local, pour le
|
||||
/// développement). Les redirections ne sont jamais suivies (le jeton ne part que vers cette adresse).
|
||||
/// </summary>
|
||||
public sealed class LauncherApiClient : IDisposable
|
||||
{
|
||||
private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
private readonly HttpClient _http;
|
||||
private readonly bool _ownsHttp;
|
||||
|
||||
/// <param name="apiBaseUrl">Adresse de base, ex. « https://launcher.lanisys.fr/api/ ».</param>
|
||||
/// <param name="handler">Pour les tests ; par défaut un gestionnaire sans redirection automatique.</param>
|
||||
public LauncherApiClient(Uri apiBaseUrl, string userAgent, HttpMessageHandler? handler = null)
|
||||
{
|
||||
if (!apiBaseUrl.IsAbsoluteUri ||
|
||||
!(apiBaseUrl.Scheme == Uri.UriSchemeHttps || (apiBaseUrl.Scheme == Uri.UriSchemeHttp && apiBaseUrl.IsLoopback)))
|
||||
throw new ArgumentException("L'adresse de l'API doit être en https://.", nameof(apiBaseUrl));
|
||||
if (!string.IsNullOrEmpty(apiBaseUrl.Query) || !string.IsNullOrEmpty(apiBaseUrl.Fragment))
|
||||
throw new ArgumentException("L'adresse de l'API ne doit contenir ni paramètres ni fragment.", nameof(apiBaseUrl));
|
||||
|
||||
BaseUrl = apiBaseUrl.AbsoluteUri.EndsWith('/') ? apiBaseUrl : new Uri(apiBaseUrl.AbsoluteUri + "/");
|
||||
Origin = SignedMessages.NormalizeOrigin(BaseUrl);
|
||||
|
||||
_ownsHttp = true;
|
||||
_http = new HttpClient(handler ?? new SocketsHttpHandler
|
||||
{
|
||||
AllowAutoRedirect = false,
|
||||
ConnectTimeout = TimeSpan.FromSeconds(15),
|
||||
AutomaticDecompression = DecompressionMethods.None,
|
||||
PooledConnectionLifetime = TimeSpan.FromMinutes(5)
|
||||
}, disposeHandler: handler is null)
|
||||
{
|
||||
Timeout = RequestTimeout
|
||||
};
|
||||
_http.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent);
|
||||
_http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
}
|
||||
|
||||
public Uri BaseUrl { get; }
|
||||
|
||||
/// <summary>Origine (« https://hote ») incluse dans les messages signés : celle de l'adresse configurée.</summary>
|
||||
public string Origin { get; }
|
||||
|
||||
public Task<ServerInfo> GetInfoAsync(CancellationToken ct = default) =>
|
||||
SendAsync<ServerInfo>(HttpMethod.Get, ApiRoutes.Info, null, null, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Connexion : défi, signature par la clé, ouverture de la session. L'heure du serveur est
|
||||
/// relevée au passage (les requêtes signées restent valides même si l'heure du PC est décalée).
|
||||
/// </summary>
|
||||
public async Task<AdminSession> LoginAsync(IAdminSigner signer, string? clientName = null, CancellationToken ct = default)
|
||||
{
|
||||
var challenge = await SendAsync<ChallengeResponse>(HttpMethod.Post, ApiRoutes.AuthChallenge,
|
||||
Serialize(new ChallengeRequest { KeyId = signer.KeyId }), null, ct).ConfigureAwait(false);
|
||||
var clockOffset = challenge.ServerTime == default ? TimeSpan.Zero : challenge.ServerTime - DateTimeOffset.UtcNow;
|
||||
if (clockOffset.Duration() > MaxClockOffset)
|
||||
throw new ApiException(0, "clock_skew",
|
||||
$"L'horloge de votre PC diffère de plus d'une heure de celle du serveur ({clockOffset.TotalHours:+0.0;-0.0} h) : corrigez la date et l'heure de Windows.");
|
||||
EnsureSameOrigin(challenge.Origin);
|
||||
|
||||
var message = SignedMessages.Login(Origin, challenge.ServerId, challenge.ChallengeId, challenge.Nonce, signer.KeyId);
|
||||
var signature = await signer.SignAsync(message, ct).ConfigureAwait(false);
|
||||
var login = await SendAsync<LoginResponse>(HttpMethod.Post, ApiRoutes.AuthLogin, Serialize(new LoginRequest
|
||||
{
|
||||
ChallengeId = challenge.ChallengeId,
|
||||
KeyId = signer.KeyId,
|
||||
Signature = Convert.ToBase64String(signature),
|
||||
ClientName = clientName
|
||||
}), null, ct).ConfigureAwait(false);
|
||||
|
||||
return new AdminSession(this, signer, challenge.ServerId, login, clockOffset);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enrôle la clé avec un code d'invitation. Renvoie l'empreinte à communiquer au propriétaire,
|
||||
/// qui doit la saisir pour approuver la clé.
|
||||
/// </summary>
|
||||
public async Task<EnrollmentResponse> EnrollAsync(string invitationCode, IAdminSigner signer, string? keyLabel,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var code = InvitationCodes.Normalize(invitationCode)
|
||||
?? throw new ApiException(400, ApiErrorCodes.InvitationInvalid, "Code d'invitation mal saisi.");
|
||||
var info = await GetInfoAsync(ct).ConfigureAwait(false);
|
||||
EnsureSameOrigin(info.Origin);
|
||||
|
||||
var proof = await signer.SignAsync(
|
||||
SignedMessages.Enrollment(Origin, info.ServerId, SignedMessages.HashInvitationCode(code), signer.KeyId), ct).ConfigureAwait(false);
|
||||
return await SendAsync<EnrollmentResponse>(HttpMethod.Post, ApiRoutes.Enrollment, Serialize(new EnrollmentRequest
|
||||
{
|
||||
InvitationCode = code,
|
||||
PublicKey = Convert.ToBase64String(signer.PublicKey),
|
||||
KeyLabel = keyLabel,
|
||||
Proof = Convert.ToBase64String(proof)
|
||||
}), null, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Transport
|
||||
|
||||
internal static byte[] Serialize<T>(T value) => JsonSerializer.SerializeToUtf8Bytes(value, ApiJson.Options);
|
||||
|
||||
/// <summary>
|
||||
/// Adresse d'une route, OBLIGATOIREMENT sous l'adresse de base : une route absolue ou
|
||||
/// « //autre-hote/… » (construite par erreur à partir d'une donnée reçue) ne doit jamais
|
||||
/// emporter le jeton ailleurs.
|
||||
/// </summary>
|
||||
internal Uri Resolve(string route)
|
||||
{
|
||||
if (string.IsNullOrEmpty(route) || route.StartsWith('/') || route.StartsWith('\\') || route.Contains("://", StringComparison.Ordinal))
|
||||
throw new ArgumentException($"Route invalide : « {route} ».", nameof(route));
|
||||
var uri = new Uri(BaseUrl, route);
|
||||
if (uri.Scheme != BaseUrl.Scheme || uri.Host != BaseUrl.Host || uri.Port != BaseUrl.Port ||
|
||||
!uri.AbsolutePath.StartsWith(BaseUrl.AbsolutePath, StringComparison.Ordinal))
|
||||
throw new ArgumentException($"Route hors de l'API : « {route} ».", nameof(route));
|
||||
return uri;
|
||||
}
|
||||
|
||||
/// <summary>Écart d'horloge maximal accepté avec le serveur (au-delà : l'heure du PC est à corriger).</summary>
|
||||
public static readonly TimeSpan MaxClockOffset = TimeSpan.FromHours(1);
|
||||
|
||||
internal async Task<T> SendAsync<T>(HttpMethod method, string route, byte[]? body,
|
||||
Action<HttpRequestMessage>? prepare, CancellationToken ct)
|
||||
{
|
||||
using var request = new HttpRequestMessage(method, Resolve(route));
|
||||
if (body is not null)
|
||||
{
|
||||
request.Content = new ByteArrayContent(body);
|
||||
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json") { CharSet = "utf-8" };
|
||||
}
|
||||
prepare?.Invoke(request);
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
throw new ApiException(0, "unreachable", $"Serveur d'administration injoignable ({ex.Message}).", ex);
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
throw new ApiException(0, "timeout", "Le serveur d'administration ne répond pas.", ex);
|
||||
}
|
||||
|
||||
using (response)
|
||||
{
|
||||
var bytes = await ReadBoundedAsync(response, ct).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode) throw ToException(response, bytes);
|
||||
if (typeof(T) == typeof(NoContent)) return (T)(object)NoContent.Value;
|
||||
try
|
||||
{
|
||||
var value = JsonSerializer.Deserialize<T>(bytes, ApiJson.Options)
|
||||
?? throw new JsonException("réponse vide");
|
||||
return value;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new ApiException((int)response.StatusCode, "invalid_response", "Réponse du serveur illisible.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<byte[]> ReadBoundedAsync(HttpResponseMessage response, CancellationToken ct)
|
||||
{
|
||||
if (response.Content.Headers.ContentLength > ApiJson.MaxResponseBytes)
|
||||
throw new ApiException((int)response.StatusCode, "invalid_response", "Réponse du serveur trop volumineuse.");
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||
using var buffer = new MemoryStream();
|
||||
var chunk = new byte[16 * 1024];
|
||||
int read;
|
||||
while ((read = await stream.ReadAsync(chunk, ct).ConfigureAwait(false)) > 0)
|
||||
{
|
||||
if (buffer.Length + read > ApiJson.MaxResponseBytes)
|
||||
throw new ApiException((int)response.StatusCode, "invalid_response", "Réponse du serveur trop volumineuse.");
|
||||
buffer.Write(chunk, 0, read);
|
||||
}
|
||||
return buffer.ToArray();
|
||||
}
|
||||
|
||||
private static ApiException ToException(HttpResponseMessage response, byte[] body)
|
||||
{
|
||||
var status = (int)response.StatusCode;
|
||||
try
|
||||
{
|
||||
if (body.Length > 0 && JsonSerializer.Deserialize<ApiError>(body, ApiJson.Options) is { Message.Length: > 0 } error)
|
||||
return new ApiException(status, error.Code, error.Message);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// réponse d'erreur non JSON (nginx, proxy…)
|
||||
}
|
||||
return status switch
|
||||
{
|
||||
401 => new ApiException(status, ApiErrorCodes.Unauthorized, "Session expirée : reconnectez-vous."),
|
||||
403 => new ApiException(status, ApiErrorCodes.Forbidden, "Action non autorisée."),
|
||||
404 => new ApiException(status, ApiErrorCodes.NotFound, "Adresse inconnue sur le serveur (version de l'API ?)."),
|
||||
413 => new ApiException(status, ApiErrorCodes.InvalidRequest, "Requête trop volumineuse."),
|
||||
429 => new ApiException(status, ApiErrorCodes.RateLimited, "Trop de requêtes : patientez une minute."),
|
||||
>= 500 => new ApiException(status, ApiErrorCodes.Error, $"Erreur du serveur ({status})."),
|
||||
_ => new ApiException(status, ApiErrorCodes.Error, $"Réponse inattendue du serveur ({status}).")
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Le serveur doit annoncer la même origine que celle configurée (sinon les signatures seraient refusées).</summary>
|
||||
private void EnsureSameOrigin(string serverOrigin)
|
||||
{
|
||||
if (!string.Equals(serverOrigin, Origin, StringComparison.OrdinalIgnoreCase))
|
||||
throw new ApiException(0, "origin_mismatch",
|
||||
$"Le serveur se présente comme « {serverOrigin} » au lieu de « {Origin} » : configuration à vérifier (PublicOrigin).");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_ownsHttp) _http.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Réponse sans contenu (204).</summary>
|
||||
public sealed class NoContent
|
||||
{
|
||||
public static readonly NoContent Value = new();
|
||||
private NoContent() { }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace GameLauncher.Contracts;
|
||||
|
||||
/// <summary>Options JSON communes au launcher et au serveur (strictes : ni commentaires, ni virgules finales).</summary>
|
||||
public static class ApiJson
|
||||
{
|
||||
public static readonly JsonSerializerOptions Options = Create();
|
||||
|
||||
/// <summary>Taille maximale d'une réponse JSON lue par le client (hors transferts de fichiers).</summary>
|
||||
public const int MaxResponseBytes = 1024 * 1024;
|
||||
|
||||
public static JsonSerializerOptions Create() => new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
ReadCommentHandling = JsonCommentHandling.Disallow,
|
||||
AllowTrailingCommas = false,
|
||||
MaxDepth = 32,
|
||||
NumberHandling = JsonNumberHandling.Strict
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
namespace GameLauncher.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Routes de la Launcher API, relatives à l'adresse de base de l'API
|
||||
/// (ex. « https://launcher.lanisys.fr/api/ »). Le serveur les publie sous « /api/ ».
|
||||
/// </summary>
|
||||
public static class ApiRoutes
|
||||
{
|
||||
/// <summary>Préfixe des routes côté serveur (nginx transmet « /api/… » sans le modifier).</summary>
|
||||
public const string Prefix = "/api";
|
||||
|
||||
public const string Version = "v1";
|
||||
|
||||
// ------------------------------------------------------------------ Système
|
||||
public const string Info = "v1/info";
|
||||
public const string Health = "v1/health";
|
||||
|
||||
// ------------------------------------------------------------------ Authentification
|
||||
public const string AuthChallenge = "v1/auth/challenge";
|
||||
public const string AuthLogin = "v1/auth/login";
|
||||
public const string AuthLogout = "v1/auth/logout";
|
||||
public const string AuthMe = "v1/auth/me";
|
||||
|
||||
/// <summary>Enrôlement d'une clé avec un code d'invitation (sans session).</summary>
|
||||
public const string Enrollment = "v1/enrollment";
|
||||
|
||||
// ------------------------------------------------------------------ Administrateurs
|
||||
public const string Admins = "v1/admins";
|
||||
public const string Invitations = "v1/admins/invitations";
|
||||
|
||||
public static string Invitation(Guid invitationId) => $"v1/admins/invitations/{invitationId:D}";
|
||||
public static string AdminRole(Guid adminId) => $"v1/admins/{adminId:D}/role";
|
||||
public static string AdminRevoke(Guid adminId) => $"v1/admins/{adminId:D}/revoke";
|
||||
public static string KeyApprove(Guid adminId, string keyId) => $"v1/admins/{adminId:D}/keys/{keyId}/approve";
|
||||
public static string KeyRevoke(Guid adminId, string keyId) => $"v1/admins/{adminId:D}/keys/{keyId}/revoke";
|
||||
|
||||
// ------------------------------------------------------------------ Audit
|
||||
public const string Audit = "v1/audit";
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Text;
|
||||
|
||||
namespace GameLauncher.Contracts;
|
||||
|
||||
/// <summary>Contrôle des textes courts saisis dans le menu d'administration (noms, libellés).</summary>
|
||||
public static class ApiText
|
||||
{
|
||||
public const int MaxDisplayNameLength = 64;
|
||||
public const int MaxKeyLabelLength = 64;
|
||||
public const int MaxClientNameLength = 96;
|
||||
|
||||
/// <summary>
|
||||
/// Texte d'une ligne : espaces normalisés, sans caractère de contrôle ni de mise en forme
|
||||
/// bidirectionnelle. Null si vide ou trop long (après nettoyage).
|
||||
/// </summary>
|
||||
public static string? CleanLine(string? value, int maxLength)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var sb = new StringBuilder(value.Length);
|
||||
var previousSpace = false;
|
||||
foreach (var c in value.Trim())
|
||||
{
|
||||
if (char.IsWhiteSpace(c))
|
||||
{
|
||||
if (!previousSpace) sb.Append(' ');
|
||||
previousSpace = true;
|
||||
continue;
|
||||
}
|
||||
if (char.IsControl(c) || IsFormatting(c)) return null;
|
||||
sb.Append(c);
|
||||
previousSpace = false;
|
||||
}
|
||||
var text = sb.ToString();
|
||||
return text.Length == 0 || text.Length > maxLength ? null : text;
|
||||
}
|
||||
|
||||
/// <summary>Caractères invisibles de mise en forme (bidirectionnels, largeur nulle…).</summary>
|
||||
private static bool IsFormatting(char c) =>
|
||||
char.GetUnicodeCategory(c) == System.Globalization.UnicodeCategory.Format;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace GameLauncher.Contracts;
|
||||
|
||||
/// <summary>Base64 « URL » sans remplissage (nonces, jetons).</summary>
|
||||
public static class Base64Url
|
||||
{
|
||||
public static string Encode(ReadOnlySpan<byte> data) =>
|
||||
Convert.ToBase64String(data).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
|
||||
public static bool TryDecode(string? text, out byte[] data)
|
||||
{
|
||||
data = Array.Empty<byte>();
|
||||
if (string.IsNullOrEmpty(text) || text.Length > 4096) return false;
|
||||
foreach (var c in text)
|
||||
if (!(c is >= 'A' and <= 'Z' or >= 'a' and <= 'z' or >= '0' and <= '9' or '-' or '_')) return false;
|
||||
|
||||
var s = text.Replace('-', '+').Replace('_', '/');
|
||||
switch (s.Length % 4)
|
||||
{
|
||||
case 2: s += "=="; break;
|
||||
case 3: s += "="; break;
|
||||
case 1: return false;
|
||||
}
|
||||
try
|
||||
{
|
||||
data = Convert.FromBase64String(s);
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
namespace GameLauncher.Contracts.Dtos;
|
||||
|
||||
public sealed record AdminKeySummary
|
||||
{
|
||||
public string KeyId { get; init; } = "";
|
||||
public string? Label { get; init; }
|
||||
public string Status { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Empreinte des clés actives ou révoquées. Volontairement absente pour une clé EN ATTENTE :
|
||||
/// l'approbateur doit saisir celle que lui communique le nouvel administrateur.
|
||||
/// </summary>
|
||||
public string? Fingerprint { get; init; }
|
||||
|
||||
public DateTimeOffset AddedAt { get; init; }
|
||||
public DateTimeOffset? LastUsedAt { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AdminSummary
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public string DisplayName { get; init; } = "";
|
||||
public string Role { get; init; } = "";
|
||||
public string Status { get; init; } = "";
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public IReadOnlyList<AdminKeySummary> Keys { get; init; } = Array.Empty<AdminKeySummary>();
|
||||
}
|
||||
|
||||
public sealed record CreateInvitationRequest
|
||||
{
|
||||
/// <summary>Nom du nouvel administrateur (ignoré pour un nouvel appareil).</summary>
|
||||
public string? DisplayName { get; init; }
|
||||
|
||||
/// <summary>Rôle du nouvel administrateur (ignoré pour un nouvel appareil).</summary>
|
||||
public string? Role { get; init; }
|
||||
|
||||
/// <summary>Renseigné : invitation « nouvel appareil » pour cet administrateur existant.</summary>
|
||||
public Guid? ForAdminId { get; init; }
|
||||
}
|
||||
|
||||
public sealed record InvitationSummary
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public string DisplayName { get; init; } = "";
|
||||
public string Role { get; init; } = "";
|
||||
public Guid? ForAdminId { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public string? CreatedBy { get; init; }
|
||||
public DateTimeOffset ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
public sealed record InvitationCreated
|
||||
{
|
||||
public InvitationSummary Invitation { get; init; } = new();
|
||||
|
||||
/// <summary>Code à transmettre au futur administrateur. Affiché UNE SEULE FOIS (le serveur n'en garde que l'empreinte).</summary>
|
||||
public string Code { get; init; } = "";
|
||||
}
|
||||
|
||||
public sealed record ApproveKeyRequest
|
||||
{
|
||||
/// <summary>Empreinte communiquée par le nouvel administrateur (« AB12 CD34 EF56 7890 1A2B »).</summary>
|
||||
public string Fingerprint { get; init; } = "";
|
||||
}
|
||||
|
||||
public sealed record ChangeRoleRequest
|
||||
{
|
||||
public string Role { get; init; } = "";
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace GameLauncher.Contracts.Dtos;
|
||||
|
||||
public sealed record AuditEntry
|
||||
{
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
/// <summary>Action, ex. « auth.login », « admins.invite ».</summary>
|
||||
public string Action { get; init; } = "";
|
||||
|
||||
/// <summary>« ok », « denied » ou « failed ».</summary>
|
||||
public string Result { get; init; } = "";
|
||||
|
||||
public Guid? ActorId { get; init; }
|
||||
public string? ActorName { get; init; }
|
||||
public string? KeyId { get; init; }
|
||||
public string? Ip { get; init; }
|
||||
public string? Target { get; init; }
|
||||
public string? Details { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AuditPage
|
||||
{
|
||||
public IReadOnlyList<AuditEntry> Entries { get; init; } = Array.Empty<AuditEntry>();
|
||||
|
||||
/// <summary>Curseur à passer dans « cursor » pour la page suivante (entrées plus anciennes), ou null.</summary>
|
||||
public string? NextCursor { get; init; }
|
||||
}
|
||||
|
||||
public static class AuditResults
|
||||
{
|
||||
public const string Ok = "ok";
|
||||
public const string Denied = "denied";
|
||||
public const string Failed = "failed";
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
namespace GameLauncher.Contracts.Dtos;
|
||||
|
||||
public sealed record ChallengeRequest
|
||||
{
|
||||
public string KeyId { get; init; } = "";
|
||||
}
|
||||
|
||||
public sealed record ChallengeResponse
|
||||
{
|
||||
public string ChallengeId { get; init; } = "";
|
||||
public string Nonce { get; init; } = "";
|
||||
public string ServerId { get; init; } = "";
|
||||
public string Origin { get; init; } = "";
|
||||
public DateTimeOffset ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>Heure du serveur : le launcher s'y cale pour horodater ses requêtes signées (PC mal réglé).</summary>
|
||||
public DateTimeOffset ServerTime { get; init; }
|
||||
}
|
||||
|
||||
public sealed record LoginRequest
|
||||
{
|
||||
public string ChallengeId { get; init; } = "";
|
||||
public string KeyId { get; init; } = "";
|
||||
|
||||
/// <summary>Signature (Base64) de <see cref="Security.SignedMessages.Login"/>.</summary>
|
||||
public string Signature { get; init; } = "";
|
||||
|
||||
/// <summary>Description du poste, pour le journal (ex. « GameLauncher 1.4.0 »). Facultatif.</summary>
|
||||
public string? ClientName { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Administrateur connecté et ses droits.</summary>
|
||||
public sealed record AdminProfile
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public string DisplayName { get; init; } = "";
|
||||
public string Role { get; init; } = "";
|
||||
public IReadOnlyList<string> Permissions { get; init; } = Array.Empty<string>();
|
||||
public string KeyId { get; init; } = "";
|
||||
public string? KeyLabel { get; init; }
|
||||
}
|
||||
|
||||
public sealed record LoginResponse
|
||||
{
|
||||
/// <summary>Jeton de session (en-tête « Authorization: Bearer … »). À garder en mémoire uniquement.</summary>
|
||||
public string Token { get; init; } = "";
|
||||
|
||||
/// <summary>Identifiant public de la session, inclus dans les requêtes signées.</summary>
|
||||
public string SessionId { get; init; } = "";
|
||||
|
||||
/// <summary>Fin de session au plus tard (durée maximale).</summary>
|
||||
public DateTimeOffset ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>Fin de session sans activité.</summary>
|
||||
public DateTimeOffset IdleExpiresAt { get; init; }
|
||||
|
||||
public AdminProfile Admin { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Session en cours (GET v1/auth/me) : prolonge la session (activité).</summary>
|
||||
public sealed record SessionInfo
|
||||
{
|
||||
public string SessionId { get; init; } = "";
|
||||
public DateTimeOffset ExpiresAt { get; init; }
|
||||
public DateTimeOffset IdleExpiresAt { get; init; }
|
||||
public AdminProfile Admin { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Enrôlement d'une clé avec un code d'invitation.</summary>
|
||||
public sealed record EnrollmentRequest
|
||||
{
|
||||
public string InvitationCode { get; init; } = "";
|
||||
|
||||
/// <summary>Clé publique (SPKI en Base64).</summary>
|
||||
public string PublicKey { get; init; } = "";
|
||||
|
||||
/// <summary>Nom du poste (ex. « PC bureau »).</summary>
|
||||
public string? KeyLabel { get; init; }
|
||||
|
||||
/// <summary>Signature (Base64) de <see cref="Security.SignedMessages.Enrollment"/> : preuve de possession.</summary>
|
||||
public string Proof { get; init; } = "";
|
||||
}
|
||||
|
||||
public sealed record EnrollmentResponse
|
||||
{
|
||||
public Guid AdminId { get; init; }
|
||||
public string DisplayName { get; init; } = "";
|
||||
public string KeyId { get; init; } = "";
|
||||
|
||||
/// <summary>Empreinte à communiquer au propriétaire, qui la saisit pour approuver.</summary>
|
||||
public string Fingerprint { get; init; } = "";
|
||||
|
||||
public string Status { get; init; } = "";
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
namespace GameLauncher.Contracts.Dtos;
|
||||
|
||||
/// <summary>Informations publiques du serveur (GET v1/info).</summary>
|
||||
public sealed record ServerInfo
|
||||
{
|
||||
/// <summary>Identifiant aléatoire propre à ce serveur, inclus dans les messages signés.</summary>
|
||||
public string ServerId { get; init; } = "";
|
||||
|
||||
/// <summary>Adresse publique de l'API (« https://hote »), incluse dans les messages signés.</summary>
|
||||
public string Origin { get; init; } = "";
|
||||
|
||||
public string ApiVersion { get; init; } = ApiRoutes.Version;
|
||||
|
||||
public string ServerVersion { get; init; } = "";
|
||||
|
||||
/// <summary>Algorithmes de clé acceptés (« ECDSA-P256-SHA256 »).</summary>
|
||||
public IReadOnlyList<string> KeyAlgorithms { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
public sealed record HealthStatus
|
||||
{
|
||||
public string Status { get; init; } = "ok";
|
||||
}
|
||||
|
||||
/// <summary>Erreur renvoyée par l'API (corps des réponses 4xx/5xx).</summary>
|
||||
public sealed record ApiError
|
||||
{
|
||||
public int Status { get; init; }
|
||||
|
||||
/// <summary>Code stable, voir <see cref="ApiErrorCodes"/>.</summary>
|
||||
public string Code { get; init; } = ApiErrorCodes.Error;
|
||||
|
||||
/// <summary>Message destiné à l'administrateur (français).</summary>
|
||||
public string Message { get; init; } = "";
|
||||
}
|
||||
|
||||
public static class ApiErrorCodes
|
||||
{
|
||||
public const string Error = "error";
|
||||
public const string InvalidRequest = "invalid_request";
|
||||
public const string Unauthorized = "unauthorized";
|
||||
public const string Forbidden = "forbidden";
|
||||
public const string NotFound = "not_found";
|
||||
public const string Conflict = "conflict";
|
||||
public const string RateLimited = "rate_limited";
|
||||
public const string SignatureRequired = "signature_required";
|
||||
public const string SignatureInvalid = "signature_invalid";
|
||||
public const string ClockSkew = "clock_skew";
|
||||
public const string Replay = "replay";
|
||||
public const string InvitationInvalid = "invitation_invalid";
|
||||
public const string FingerprintMismatch = "fingerprint_mismatch";
|
||||
public const string LastOwner = "last_owner";
|
||||
public const string SelfAction = "self_action";
|
||||
public const string Busy = "busy";
|
||||
public const string HttpsRequired = "https_required";
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
Protocole partagé entre le launcher et la Launcher API (serveur) : routes, objets échangés,
|
||||
rôles et permissions, messages signés et contrôle des clés publiques.
|
||||
Aucune dépendance (ni WPF, ni ASP.NET Core) : référencé par le launcher, le serveur et les outils.
|
||||
Voir docs\ADMIN-ARCHITECTURE.md.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RootNamespace>GameLauncher.Contracts</RootNamespace>
|
||||
<AssemblyName>GameLauncher.Contracts</AssemblyName>
|
||||
<Platforms>AnyCPU;x64</Platforms>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,158 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace GameLauncher.Contracts.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Clés des administrateurs : ECDSA P-256, signatures SHA-256 au format IEEE P1363 (64 octets).
|
||||
/// Même famille que la signature des mises à jour du launcher ; tout est fourni par .NET.
|
||||
/// </summary>
|
||||
public static class AdminKeys
|
||||
{
|
||||
public const string Algorithm = "ECDSA-P256-SHA256";
|
||||
public const int SignatureLength = 64;
|
||||
|
||||
/// <summary>Taille maximale d'une clé publique SPKI encodée (une clé P-256 fait 91 octets).</summary>
|
||||
private const int MaxSpkiLength = 512;
|
||||
|
||||
private const DSASignatureFormat Format = DSASignatureFormat.IeeeP1363FixedFieldConcatenation;
|
||||
private const string P256Oid = "1.2.840.10045.3.1.7";
|
||||
|
||||
/// <summary>Identifiant d'une clé : 16 premiers caractères hexadécimaux (minuscules) du SHA-256 de la clé publique.</summary>
|
||||
public static string ComputeKeyId(ReadOnlySpan<byte> subjectPublicKeyInfo) =>
|
||||
Convert.ToHexString(SHA256.HashData(subjectPublicKeyInfo))[..16].ToLowerInvariant();
|
||||
|
||||
/// <summary>Nombre de caractères hexadécimaux de l'empreinte (80 bits).</summary>
|
||||
public const int FingerprintLength = 20;
|
||||
|
||||
/// <summary>
|
||||
/// Empreinte à comparer entre humains (téléphone, message) : « AB12 CD34 EF56 7890 1A2B » (80 bits).
|
||||
/// Calculée avec une séparation de domaine, donc SANS RAPPORT avec <see cref="ComputeKeyId"/> :
|
||||
/// connaître l'identifiant d'une clé (visible dans les listes et les adresses) ne donne pas son
|
||||
/// empreinte. Seule la personne qui détient la clé peut la communiquer.
|
||||
/// </summary>
|
||||
public static string ComputeFingerprint(ReadOnlySpan<byte> subjectPublicKeyInfo)
|
||||
{
|
||||
var prefix = System.Text.Encoding.ASCII.GetBytes("GameLauncher/Admin/Fingerprint/v1\n");
|
||||
var data = new byte[prefix.Length + subjectPublicKeyInfo.Length];
|
||||
prefix.CopyTo(data, 0);
|
||||
subjectPublicKeyInfo.CopyTo(data.AsSpan(prefix.Length));
|
||||
var hex = Convert.ToHexString(SHA256.HashData(data))[..FingerprintLength];
|
||||
return string.Join(' ', Enumerable.Range(0, FingerprintLength / 4).Select(i => hex.Substring(i * 4, 4)));
|
||||
}
|
||||
|
||||
/// <summary>Empreinte saisie par un humain → 20 caractères hexadécimaux majuscules, ou null.</summary>
|
||||
public static string? NormalizeFingerprint(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text) || text.Length > 64) return null;
|
||||
var hex = new string(text.Where(c => !char.IsWhiteSpace(c) && c != '-' && c != ':').ToArray()).ToUpperInvariant();
|
||||
return hex.Length == FingerprintLength && hex.All(Uri.IsHexDigit) ? hex : null;
|
||||
}
|
||||
|
||||
/// <summary>Compare une empreinte saisie avec celle d'une clé (en temps constant).</summary>
|
||||
public static bool FingerprintMatches(ReadOnlySpan<byte> subjectPublicKeyInfo, string? typed)
|
||||
{
|
||||
var normalized = NormalizeFingerprint(typed);
|
||||
if (normalized is null) return false;
|
||||
var expected = ComputeFingerprint(subjectPublicKeyInfo).Replace(" ", "");
|
||||
return CryptographicOperations.FixedTimeEquals(
|
||||
System.Text.Encoding.ASCII.GetBytes(expected), System.Text.Encoding.ASCII.GetBytes(normalized));
|
||||
}
|
||||
|
||||
public static bool IsValidKeyId(string? keyId) =>
|
||||
keyId is { Length: 16 } && keyId.All(c => c is >= '0' and <= '9' or >= 'a' and <= 'f');
|
||||
|
||||
/// <summary>
|
||||
/// Lit une clé publique (SPKI en Base64, ou PEM « BEGIN PUBLIC KEY ») et vérifie qu'il s'agit
|
||||
/// d'une clé ECDSA P-256 valide. Renvoie le SPKI ré-encodé (forme canonique).
|
||||
/// </summary>
|
||||
/// <exception cref="CryptographicException">Clé illisible ou d'un autre type.</exception>
|
||||
public static byte[] ParsePublicKey(string text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text) || text.Length > 4096)
|
||||
throw new CryptographicException("Clé publique absente ou trop longue.");
|
||||
|
||||
using var ec = ECDsa.Create();
|
||||
try
|
||||
{
|
||||
if (text.Contains("-----BEGIN", StringComparison.Ordinal))
|
||||
{
|
||||
if (!text.Contains("-----BEGIN PUBLIC KEY-----", StringComparison.Ordinal))
|
||||
throw new CryptographicException("Une clé PUBLIQUE est attendue (jamais la clé privée).");
|
||||
ec.ImportFromPem(text);
|
||||
}
|
||||
else
|
||||
{
|
||||
var der = Convert.FromBase64String(text.Trim());
|
||||
if (der.Length > MaxSpkiLength) throw new CryptographicException("Clé publique trop longue.");
|
||||
ec.ImportSubjectPublicKeyInfo(der, out var read);
|
||||
if (read != der.Length) throw new CryptographicException("Clé publique mal formée.");
|
||||
}
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new CryptographicException("Clé publique illisible.", ex);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
throw new CryptographicException("Clé publique illisible.", ex);
|
||||
}
|
||||
|
||||
EnsureP256(ec);
|
||||
return ec.ExportSubjectPublicKeyInfo();
|
||||
}
|
||||
|
||||
/// <summary>Clé publique au format PEM (export, outil en ligne de commande).</summary>
|
||||
public static string ToPem(ReadOnlySpan<byte> subjectPublicKeyInfo) =>
|
||||
PemEncoding.WriteString("PUBLIC KEY", subjectPublicKeyInfo);
|
||||
|
||||
/// <summary>Signe un message (côté launcher).</summary>
|
||||
public static byte[] Sign(ECDsa privateKey, ReadOnlySpan<byte> message)
|
||||
{
|
||||
EnsureP256(privateKey);
|
||||
return privateKey.SignData(message, HashAlgorithmName.SHA256, Format);
|
||||
}
|
||||
|
||||
/// <summary>Vérifie une signature (côté serveur). Ne lève jamais d'exception : faux si quoi que ce soit ne va pas.</summary>
|
||||
public static bool Verify(ReadOnlySpan<byte> subjectPublicKeyInfo, ReadOnlySpan<byte> message, ReadOnlySpan<byte> signature)
|
||||
{
|
||||
if (signature.Length != SignatureLength || subjectPublicKeyInfo.Length is 0 or > MaxSpkiLength) return false;
|
||||
try
|
||||
{
|
||||
using var ec = ECDsa.Create();
|
||||
ec.ImportSubjectPublicKeyInfo(subjectPublicKeyInfo, out _);
|
||||
if (!IsP256(ec)) return false;
|
||||
return ec.VerifyData(message, signature, HashAlgorithmName.SHA256, Format);
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Signature encodée en Base64 → octets, ou null si l'encodage ou la taille ne conviennent pas.</summary>
|
||||
public static byte[]? DecodeSignature(string? base64)
|
||||
{
|
||||
if (string.IsNullOrEmpty(base64) || base64.Length > 128) return null;
|
||||
try
|
||||
{
|
||||
var bytes = Convert.FromBase64String(base64);
|
||||
return bytes.Length == SignatureLength ? bytes : null;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureP256(ECDsa ec)
|
||||
{
|
||||
if (!IsP256(ec)) throw new CryptographicException("Une clé ECDSA P-256 est attendue.");
|
||||
}
|
||||
|
||||
private static bool IsP256(ECDsa ec)
|
||||
{
|
||||
if (ec.KeySize != 256) return false;
|
||||
var curve = ec.ExportParameters(false).Curve;
|
||||
return curve.IsNamed && (curve.Oid.Value == P256Oid || curve.Oid.FriendlyName is "nistP256" or "ECDSA_P256");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace GameLauncher.Contracts.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Permissions d'administration, au format « module.action ». Un module futur ajoute les siennes
|
||||
/// ici (ou dans son propre fichier de contrats) et les attribue aux rôles dans <see cref="AdminRoles"/>.
|
||||
/// Le serveur est seul juge : côté launcher, elles servent uniquement à masquer les pages inutiles.
|
||||
/// </summary>
|
||||
public static class AdminPermissions
|
||||
{
|
||||
/// <summary>Gestion des administrateurs : invitations, approbation, rôles, révocation.</summary>
|
||||
public const string AdminsManage = "admins.manage";
|
||||
|
||||
/// <summary>Lecture du journal d'audit.</summary>
|
||||
public const string AuditRead = "audit.read";
|
||||
|
||||
/// <summary>Informations modifiables des jeux (nom affiché, accroche, liens…) — phase 3.</summary>
|
||||
public const string GamesEdit = "games.edit";
|
||||
|
||||
/// <summary>Édition des notes de version existantes — phase 4.</summary>
|
||||
public const string ChangelogEdit = "changelog.edit";
|
||||
|
||||
/// <summary>Publication d'une nouvelle version du jeu — phase 5.</summary>
|
||||
public const string ReleasesPublish = "releases.publish";
|
||||
|
||||
public static readonly IReadOnlyList<string> All = new[]
|
||||
{
|
||||
AdminsManage, AuditRead, GamesEdit, ChangelogEdit, ReleasesPublish
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
namespace GameLauncher.Contracts.Security;
|
||||
|
||||
/// <summary>Rôles des administrateurs et permissions associées.</summary>
|
||||
public static class AdminRoles
|
||||
{
|
||||
/// <summary>Tous les droits, dont la gestion des administrateurs.</summary>
|
||||
public const string Owner = "owner";
|
||||
|
||||
/// <summary>Contenu et publication, sans gestion des administrateurs.</summary>
|
||||
public const string Admin = "admin";
|
||||
|
||||
/// <summary>Contenu uniquement (informations des jeux, notes de version).</summary>
|
||||
public const string Editor = "editor";
|
||||
|
||||
public static readonly IReadOnlyList<string> All = new[] { Owner, Admin, Editor };
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, IReadOnlyList<string>> Map =
|
||||
new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal)
|
||||
{
|
||||
[Owner] = AdminPermissions.All,
|
||||
[Admin] = new[]
|
||||
{
|
||||
AdminPermissions.AuditRead, AdminPermissions.GamesEdit,
|
||||
AdminPermissions.ChangelogEdit, AdminPermissions.ReleasesPublish
|
||||
},
|
||||
[Editor] = new[] { AdminPermissions.GamesEdit, AdminPermissions.ChangelogEdit }
|
||||
};
|
||||
|
||||
public static bool IsValid(string? role) => role is not null && Map.ContainsKey(role);
|
||||
|
||||
/// <summary>Permissions d'un rôle (aucune pour un rôle inconnu).</summary>
|
||||
public static IReadOnlyList<string> PermissionsOf(string? role) =>
|
||||
role is not null && Map.TryGetValue(role, out var permissions) ? permissions : Array.Empty<string>();
|
||||
|
||||
public static bool Has(string? role, string permission) => PermissionsOf(role).Contains(permission, StringComparer.Ordinal);
|
||||
|
||||
public static string DisplayName(string? role) => role switch
|
||||
{
|
||||
Owner => "Propriétaire",
|
||||
Admin => "Administrateur",
|
||||
Editor => "Éditeur",
|
||||
_ => "Inconnu"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>États d'un administrateur ou d'une clé.</summary>
|
||||
public static class AdminStatus
|
||||
{
|
||||
/// <summary>Enrôlé avec une invitation, en attente d'approbation (empreinte vérifiée par un propriétaire).</summary>
|
||||
public const string Pending = "pending";
|
||||
public const string Active = "active";
|
||||
public const string Revoked = "revoked";
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace GameLauncher.Contracts.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Codes d'invitation : 160 bits aléatoires, en Base32 (sans 0/1/8/9 ambigus), groupés par 4 :
|
||||
/// « ABCD-EFGH-IJKL-MNOP-QRST-UVWX-YZ23-4567 ». Le serveur ne garde que leur empreinte.
|
||||
/// </summary>
|
||||
public static class InvitationCodes
|
||||
{
|
||||
private const string Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
private const int Length = 32; // 32 × 5 bits = 160 bits
|
||||
|
||||
public static string Generate()
|
||||
{
|
||||
var bytes = RandomNumberGenerator.GetBytes(Length * 5 / 8);
|
||||
var chars = new char[Length];
|
||||
int buffer = 0, bits = 0, index = 0;
|
||||
foreach (var b in bytes)
|
||||
{
|
||||
buffer = (buffer << 8) | b;
|
||||
bits += 8;
|
||||
while (bits >= 5)
|
||||
{
|
||||
chars[index++] = Alphabet[(buffer >> (bits - 5)) & 31];
|
||||
bits -= 5;
|
||||
}
|
||||
}
|
||||
var sb = new StringBuilder();
|
||||
for (var i = 0; i < Length; i++)
|
||||
{
|
||||
if (i > 0 && i % 4 == 0) sb.Append('-');
|
||||
sb.Append(chars[i]);
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Code saisi → 32 caractères majuscules sans séparateurs, ou null s'il est mal formé.</summary>
|
||||
public static string? Normalize(string? typed)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(typed) || typed.Length > 80) return null;
|
||||
var sb = new StringBuilder(Length);
|
||||
foreach (var c in typed)
|
||||
{
|
||||
if (c is '-' or ' ') continue;
|
||||
var u = char.ToUpperInvariant(c);
|
||||
if (Alphabet.IndexOf(u) < 0) return null;
|
||||
sb.Append(u);
|
||||
}
|
||||
return sb.Length == Length ? sb.ToString() : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace GameLauncher.Contracts.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Messages signés par la clé d'un administrateur. Chaque type de message commence par un en-tête
|
||||
/// propre (séparation de domaine) : une signature produite pour un usage ne vaut jamais pour un
|
||||
/// autre. Une ligne par champ, « nom:valeur », valeurs sans retour à la ligne, UTF-8, « \n ».
|
||||
/// Le launcher et le serveur construisent le message avec ce même code.
|
||||
/// </summary>
|
||||
public static class SignedMessages
|
||||
{
|
||||
public const string LoginHeader = "GameLauncher/Admin/Login/v1";
|
||||
public const string EnrollmentHeader = "GameLauncher/Admin/Enrollment/v1";
|
||||
public const string RequestHeader = "GameLauncher/Admin/Request/v1";
|
||||
|
||||
/// <summary>Message signé pour se connecter (réponse au défi).</summary>
|
||||
public static byte[] Login(string origin, string serverId, string challengeId, string nonce, string keyId) =>
|
||||
Build(LoginHeader,
|
||||
("origin", origin), ("server", serverId), ("challenge", challengeId), ("nonce", nonce), ("key", keyId));
|
||||
|
||||
/// <summary>
|
||||
/// Preuve de possession de la clé lors d'un enrôlement : seule l'empreinte du code d'invitation est
|
||||
/// signée (le code lui-même n'apparaît pas dans la signature).
|
||||
/// </summary>
|
||||
public static byte[] Enrollment(string origin, string serverId, string invitationCodeHash, string keyId) =>
|
||||
Build(EnrollmentHeader,
|
||||
("origin", origin), ("server", serverId), ("invitation", invitationCodeHash), ("key", keyId));
|
||||
|
||||
/// <summary>Message signé d'une requête qui modifie quelque chose (en plus du jeton de session).</summary>
|
||||
/// <param name="pathAndQuery">Chemin tel que reçu par le serveur, ex. « /api/v1/admins/invitations ».</param>
|
||||
public static byte[] Request(string origin, string serverId, string sessionId, string method, string pathAndQuery,
|
||||
ReadOnlySpan<byte> body, long timestamp, string nonce) =>
|
||||
Build(RequestHeader,
|
||||
("origin", origin), ("server", serverId), ("session", sessionId),
|
||||
("method", method.ToUpperInvariant()), ("path", pathAndQuery),
|
||||
("body-sha256", Convert.ToHexString(SHA256.HashData(body)).ToLowerInvariant()),
|
||||
("timestamp", timestamp.ToString(System.Globalization.CultureInfo.InvariantCulture)), ("nonce", nonce));
|
||||
|
||||
/// <summary>Empreinte d'un code d'invitation (normalisé) : seule valeur conservée par le serveur.</summary>
|
||||
public static string HashInvitationCode(string normalizedCode) =>
|
||||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes("GameLauncher/Admin/Invitation/v1\n" + normalizedCode)))
|
||||
.ToLowerInvariant();
|
||||
|
||||
/// <summary>Origine normalisée d'une adresse : « https://hote[:port] », sans chemin ni barre finale.</summary>
|
||||
public static string NormalizeOrigin(Uri uri) => uri.GetLeftPart(UriPartial.Authority).TrimEnd('/').ToLowerInvariant();
|
||||
|
||||
private static byte[] Build(string header, params (string Name, string Value)[] fields)
|
||||
{
|
||||
var sb = new StringBuilder(header).Append('\n');
|
||||
foreach (var (name, value) in fields)
|
||||
{
|
||||
if (value is null || value.IndexOfAny(new[] { '\n', '\r' }) >= 0 || value.Length > 2048)
|
||||
throw new ArgumentException($"Valeur invalide pour le champ signé « {name} ».");
|
||||
sb.Append(name).Append(':').Append(value).Append('\n');
|
||||
}
|
||||
return Encoding.UTF8.GetBytes(sb.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>En-têtes HTTP des requêtes signées et tolérances associées.</summary>
|
||||
public static class SignedRequestHeaders
|
||||
{
|
||||
public const string Timestamp = "X-GL-Timestamp";
|
||||
public const string Nonce = "X-GL-Nonce";
|
||||
public const string Signature = "X-GL-Signature";
|
||||
|
||||
/// <summary>Décalage d'horloge maximal accepté entre le launcher et le serveur.</summary>
|
||||
public static readonly TimeSpan MaxClockSkew = TimeSpan.FromMinutes(2);
|
||||
}
|
||||
@@ -15,7 +15,16 @@ namespace GameLauncher.Core.Models;
|
||||
/// </summary>
|
||||
public sealed class LauncherCatalog
|
||||
{
|
||||
private static readonly string[] RootOnlyProperties = { "games", "launcherId", "launcherName", "defaultGame", "launcherUpdateUrl" };
|
||||
private static readonly string[] RootOnlyProperties = { "games", "launcherId", "launcherName", "defaultGame", "launcherUpdateUrl", "dataFolder" };
|
||||
|
||||
/// <summary>Nom par défaut du dossier des données du launcher (%AppData%\<Publisher>\GameLauncher).</summary>
|
||||
public const string DefaultDataFolderName = "GameLauncher";
|
||||
|
||||
private const string GamesFolderName = "Games";
|
||||
private const string GameSettingsFileName = "settings.json";
|
||||
|
||||
private string? _dataDirectory;
|
||||
private readonly Dictionary<string, string> _gameSettingsPaths = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private LauncherCatalog(string launcherId, string launcherName, string publisher, string? defaultGameId,
|
||||
IReadOnlyList<LauncherConfig> games, bool isMultiGameFormat)
|
||||
@@ -54,11 +63,138 @@ public sealed class LauncherCatalog
|
||||
public string? LauncherUpdateUrl { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Dossier des données du launcher (journaux, dernier jeu sélectionné).
|
||||
/// Avec un seul jeu, c'est le même dossier qu'auparavant : %AppData%\Publisher\GameIdLauncher.
|
||||
/// Nom du dossier des données du launcher et de sa clé de registre (« dataFolder » dans
|
||||
/// launcher.json, « GameLauncher » par défaut). Indépendant de <see cref="LauncherId"/>, qui
|
||||
/// identifie le produit lors des mises à jour et ne doit plus changer.
|
||||
/// </summary>
|
||||
public string DataDirectory =>
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), Publisher, LauncherId);
|
||||
public string DataFolderName { get; private set; } = DefaultDataFolderName;
|
||||
|
||||
/// <summary>%AppData%\<Publisher></summary>
|
||||
public string PublisherDirectory =>
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), Publisher);
|
||||
|
||||
/// <summary>
|
||||
/// Dossier des données du launcher : %AppData%\<Publisher>\GameLauncher (paramètres du
|
||||
/// launcher, mise à jour du launcher, journaux, dernier jeu affiché ; paramètres de chaque jeu
|
||||
/// dans Games\<GameId>). Voir <see cref="PrepareStorage"/>.
|
||||
/// </summary>
|
||||
public string DataDirectory => _dataDirectory ?? Path.Combine(PublisherDirectory, DataFolderName);
|
||||
|
||||
/// <summary>
|
||||
/// Ancien dossier des données (launcher 1.2.1 et antérieurs) : %AppData%\<Publisher>\<LauncherId>
|
||||
/// (ex. EZRLauncher avec un seul jeu).
|
||||
/// </summary>
|
||||
public string LegacyDataDirectory => Path.Combine(PublisherDirectory, LauncherId);
|
||||
|
||||
/// <summary>Vrai si l'ancien dossier est encore utilisé pour cette session (déplacement différé ou impossible).</summary>
|
||||
public bool UsesLegacyStorage { get; private set; }
|
||||
|
||||
/// <summary>Paramètres propres à un jeu : <DataDirectory>\Games\<GameId>\settings.json.</summary>
|
||||
public string GetGameSettingsPath(LauncherConfig game) =>
|
||||
_gameSettingsPaths.TryGetValue(game.GameId, out var path)
|
||||
? path
|
||||
: Path.Combine(DataDirectory, GamesFolderName, game.GameId, GameSettingsFileName);
|
||||
|
||||
/// <summary>
|
||||
/// Détermine les dossiers à utiliser et, si <paramref name="allowMigration"/>, déplace les données
|
||||
/// des versions précédentes :
|
||||
/// • %AppData%\<Publisher>\<LauncherId> (ex. EZRLauncher) → %AppData%\<Publisher>\GameLauncher
|
||||
/// (renommage du dossier, instantané) ;
|
||||
/// • paramètres de chaque jeu (settings.json à la racine de l'ancien dossier, ou dans
|
||||
/// %AppData%\<Publisher>\<GameId>Launcher) → GameLauncher\Games\<GameId>\settings.json.
|
||||
/// Sans déplacement (autre instance ouverte, démarrage après une mise à jour du launcher) ou en cas
|
||||
/// d'échec, l'ancien dossier reste utilisé pour cette session : rien n'est perdu, le déplacement est
|
||||
/// retenté au démarrage suivant.
|
||||
/// </summary>
|
||||
/// <returns>Messages à écrire dans le journal (le journal n'est pas encore ouvert) ; « ! » = avertissement.</returns>
|
||||
public IReadOnlyList<string> PrepareStorage(bool allowMigration)
|
||||
{
|
||||
var notes = new List<string>();
|
||||
var target = Path.Combine(PublisherDirectory, DataFolderName);
|
||||
var legacy = LegacyDataDirectory;
|
||||
_dataDirectory = target;
|
||||
UsesLegacyStorage = false;
|
||||
_gameSettingsPaths.Clear();
|
||||
|
||||
if (!SamePath(target, legacy) && !Directory.Exists(target) && Directory.Exists(legacy))
|
||||
{
|
||||
if (!allowMigration)
|
||||
{
|
||||
UseLegacy();
|
||||
notes.Add($"Dossier des données : {legacy} utilisé pour cette session (déplacement vers {target} au prochain démarrage).");
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Move(legacy, target); // même dossier parent : simple renommage
|
||||
notes.Add($"Données du launcher déplacées : {legacy} → {target}");
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
UseLegacy();
|
||||
notes.Add($"! Déplacement de {legacy} vers {target} impossible ({ex.Message}) : l'ancien dossier reste utilisé.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var game in Games)
|
||||
{
|
||||
var legacyGameDirectory = Path.Combine(PublisherDirectory, game.GameId + "Launcher");
|
||||
if (UsesLegacyStorage)
|
||||
{
|
||||
_gameSettingsPaths[game.GameId] = Path.Combine(legacyGameDirectory, GameSettingsFileName);
|
||||
continue;
|
||||
}
|
||||
|
||||
var path = Path.Combine(target, GamesFolderName, game.GameId, GameSettingsFileName);
|
||||
_gameSettingsPaths[game.GameId] = path;
|
||||
if (File.Exists(path)) continue;
|
||||
|
||||
// Ancien emplacement : à la racine du dossier déplacé (un seul jeu : même dossier que le
|
||||
// launcher) ou dans le dossier propre au jeu (plusieurs jeux).
|
||||
var candidates = new List<string>();
|
||||
if (SamePath(legacyGameDirectory, legacy)) candidates.Add(Path.Combine(target, GameSettingsFileName));
|
||||
candidates.Add(Path.Combine(legacyGameDirectory, GameSettingsFileName));
|
||||
var source = candidates.FirstOrDefault(File.Exists);
|
||||
if (source is null) continue;
|
||||
|
||||
if (!allowMigration)
|
||||
{
|
||||
_gameSettingsPaths[game.GameId] = source;
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
File.Move(source, path);
|
||||
notes.Add($"[{game.GameId}] Paramètres du jeu déplacés : {source} → {path}");
|
||||
|
||||
// Ancien dossier propre au jeu, désormais vide : supprimé.
|
||||
var sourceDirectory = Path.GetDirectoryName(source)!;
|
||||
if (!SamePath(sourceDirectory, target) && Directory.Exists(sourceDirectory) &&
|
||||
!Directory.EnumerateFileSystemEntries(sourceDirectory).Any())
|
||||
Directory.Delete(sourceDirectory);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_gameSettingsPaths[game.GameId] = source;
|
||||
notes.Add($"! [{game.GameId}] Déplacement des paramètres du jeu impossible ({ex.Message}) : {source} reste utilisé.");
|
||||
}
|
||||
}
|
||||
return notes;
|
||||
|
||||
void UseLegacy()
|
||||
{
|
||||
_dataDirectory = legacy;
|
||||
UsesLegacyStorage = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool SamePath(string a, string b) =>
|
||||
string.Equals(Path.GetFullPath(a).TrimEnd(Path.DirectorySeparatorChar),
|
||||
Path.GetFullPath(b).TrimEnd(Path.DirectorySeparatorChar), StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Nom du mutex garantissant une seule instance du launcher.</summary>
|
||||
public string SingleInstanceName =>
|
||||
@@ -103,7 +239,8 @@ public sealed class LauncherCatalog
|
||||
return new LauncherCatalog(single.GameId + "Launcher", single.GameName, single.Publisher, single.GameId,
|
||||
new[] { single }, isMultiGameFormat: false)
|
||||
{
|
||||
LauncherUpdateUrl = ReadUpdateUrl(root)
|
||||
LauncherUpdateUrl = ReadUpdateUrl(root),
|
||||
DataFolderName = ReadDataFolder(root)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -153,10 +290,21 @@ public sealed class LauncherCatalog
|
||||
var defaultGame = Get(root, "defaultGame")?.GetValue<string>();
|
||||
return new LauncherCatalog(launcherId, launcherName, publisher, defaultGame, games, isMultiGameFormat: true)
|
||||
{
|
||||
LauncherUpdateUrl = ReadUpdateUrl(root)
|
||||
LauncherUpdateUrl = ReadUpdateUrl(root),
|
||||
DataFolderName = ReadDataFolder(root)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>« dataFolder » : nom simple de dossier (« GameLauncher » par défaut).</summary>
|
||||
private static string ReadDataFolder(JsonObject root)
|
||||
{
|
||||
var name = Get(root, "dataFolder")?.GetValue<string>()?.Trim();
|
||||
if (string.IsNullOrEmpty(name)) return DefaultDataFolderName;
|
||||
if (name is "." or ".." || name.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 || name.IndexOfAny(new[] { '\\', '/', ':' }) >= 0)
|
||||
throw new InvalidDataException("launcher.json : dataFolder doit être un simple nom de dossier (ex. \"GameLauncher\").");
|
||||
return name;
|
||||
}
|
||||
|
||||
private static string? ReadUpdateUrl(JsonObject root)
|
||||
{
|
||||
var url = Get(root, "launcherUpdateUrl")?.GetValue<string>();
|
||||
|
||||
@@ -47,6 +47,38 @@ public sealed class LauncherConfig
|
||||
/// <summary>Image de fond remplaçant celle du thème (image, chemin relatif au launcher). Facultatif.</summary>
|
||||
public string? Background { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Dossier des données du jeu Unity : « Société\Produit » des Player Settings de Unity
|
||||
/// (ex. "LanisysGames\\Exovival Zombie Reborn"). Sert à ouvrir, depuis les paramètres du jeu,
|
||||
/// le dossier des données (%APPDATA%\Société\Produit : sauvegardes, réglages) et celui des
|
||||
/// journaux de Unity (%USERPROFILE%\AppData\LocalLow\Société\Produit : Player.log).
|
||||
/// Facultatif : Publisher\GameName par défaut.
|
||||
/// </summary>
|
||||
public string? GameDataFolder { get; set; }
|
||||
|
||||
/// <summary>Chemin relatif « Société\Produit » (voir <see cref="GameDataFolder"/>).</summary>
|
||||
public string GameDataRelativePath =>
|
||||
(string.IsNullOrWhiteSpace(GameDataFolder) ? Path.Combine(Publisher, GameName) : GameDataFolder.Trim())
|
||||
.Replace('/', '\\').Trim('\\');
|
||||
|
||||
/// <summary>Sauvegardes et réglages du jeu : %APPDATA%\Société\Produit (Roaming).</summary>
|
||||
public string GameDataDirectory =>
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), GameDataRelativePath);
|
||||
|
||||
/// <summary>
|
||||
/// Journaux de Unity (Player.log) : %USERPROFILE%\AppData\LocalLow\Société\Produit. Emplacement fixé
|
||||
/// par Unity (Application.persistentDataPath) : LocalLow est le dossier voisin de %LOCALAPPDATA%.
|
||||
/// </summary>
|
||||
public string GameLogsDirectory
|
||||
{
|
||||
get
|
||||
{
|
||||
var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
var appData = Path.GetDirectoryName(local.TrimEnd(Path.DirectorySeparatorChar)) ?? local;
|
||||
return Path.Combine(appData, "LocalLow", GameDataRelativePath);
|
||||
}
|
||||
}
|
||||
|
||||
public string ExpandedDefaultInstallDirectory =>
|
||||
Environment.ExpandEnvironmentVariables(DefaultInstallDirectory ?? "");
|
||||
|
||||
@@ -81,5 +113,17 @@ public sealed class LauncherConfig
|
||||
throw new InvalidDataException("launcher.json : GameName est obligatoire.");
|
||||
if (!Uri.TryCreate(ManifestUrl, UriKind.Absolute, out var uri) || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
|
||||
throw new InvalidDataException("launcher.json : ManifestUrl doit être une URL http(s) absolue.");
|
||||
if (!IsSafeRelativeFolder(GameDataRelativePath))
|
||||
throw new InvalidDataException(
|
||||
"launcher.json : gameDataFolder doit être un chemin relatif « Société\\Produit » (sans lecteur, sans « .. »).");
|
||||
}
|
||||
|
||||
/// <summary>Chemin relatif simple : pas de lecteur, pas de « .. », pas de caractère interdit.</summary>
|
||||
private static bool IsSafeRelativeFolder(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || Path.IsPathRooted(path) || path.Contains(':')) return false;
|
||||
var invalid = Path.GetInvalidFileNameChars();
|
||||
return path.Split('\\').All(part => part.Length > 0 && part != "." && part != ".." &&
|
||||
part.Trim() == part && part.IndexOfAny(invalid) < 0);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ namespace GameLauncher.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Préférences du launcher, communes à tous les jeux (roue crantée › Paramètres du launcher) :
|
||||
/// %AppData%\<Publisher>\<LauncherId>\launcher-settings.json.
|
||||
/// %AppData%\<Publisher>\GameLauncher\launcher-settings.json (voir LauncherCatalog.DataDirectory).
|
||||
/// (La mise à jour automatique du launcher lui-même est dans self-update.json.)
|
||||
/// </summary>
|
||||
public sealed class LauncherPreferences
|
||||
@@ -16,6 +16,18 @@ public sealed class LauncherPreferences
|
||||
/// <summary>Fermer le launcher au lancement du jeu.</summary>
|
||||
public bool CloseOnLaunch { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Avec <see cref="MinimizeOnLaunch"/> : pendant la partie, le launcher est réduit dans la zone de
|
||||
/// notification (icône près de l'horloge) au lieu de la barre des tâches.
|
||||
/// </summary>
|
||||
public bool MinimizeToTray { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// La croix de la fenêtre réduit le launcher dans la zone de notification au lieu de le quitter
|
||||
/// (il se quitte alors par le menu de son icône › Quitter).
|
||||
/// </summary>
|
||||
public bool CloseToTray { get; set; }
|
||||
|
||||
public static string GetPath(string dataDirectory) => Path.Combine(dataDirectory, "launcher-settings.json");
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -5,7 +5,8 @@ namespace GameLauncher.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Préférences propres à UN jeu (menu « … » › Paramètres du jeu) :
|
||||
/// %AppData%\{Publisher}\{GameId}Launcher\settings.json.
|
||||
/// %AppData%\{Publisher}\GameLauncher\Games\{GameId}\settings.json (voir LauncherCatalog.GetGameSettingsPath ;
|
||||
/// launcher 1.2.1 et antérieurs : %AppData%\{Publisher}\{GameId}Launcher\settings.json).
|
||||
/// Les préférences communes à tous les jeux sont dans <see cref="LauncherPreferences"/>.
|
||||
/// </summary>
|
||||
public sealed class LauncherSettings
|
||||
@@ -26,13 +27,6 @@ public sealed class LauncherSettings
|
||||
|
||||
public LauncherSettings Clone() => (LauncherSettings)MemberwiseClone();
|
||||
|
||||
public static string GetDefaultPath(LauncherConfig config) =>
|
||||
Path.Combine(GetDataDirectory(config), "settings.json");
|
||||
|
||||
/// <summary>%AppData%\{Publisher}\{GameId}Launcher</summary>
|
||||
public static string GetDataDirectory(LauncherConfig config) =>
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
config.Publisher, config.GameId + "Launcher");
|
||||
|
||||
public static LauncherSettings Load(string path)
|
||||
{
|
||||
|
||||
@@ -24,4 +24,14 @@ public sealed record VerifyResult(
|
||||
}
|
||||
|
||||
/// <summary>Résultat d'une désinstallation.</summary>
|
||||
public sealed record UninstallResult(int DeletedFiles, int RemainingFiles, bool FolderRemoved);
|
||||
/// <param name="DeletedFiles">Fichiers supprimés.</param>
|
||||
/// <param name="RemainingFiles">Fichiers personnels conservés (hors fichiers bloqués et fichiers du launcher).</param>
|
||||
/// <param name="FolderRemoved">Le dossier du jeu a disparu.</param>
|
||||
public sealed record UninstallResult(int DeletedFiles, int RemainingFiles, bool FolderRemoved)
|
||||
{
|
||||
/// <summary>Fichiers qui n'ont pas pu être supprimés (fichier ouvert, antivirus…).</summary>
|
||||
public IReadOnlyList<string> LockedFiles { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>Fichiers du launcher conservés (launcher placé dans le dossier du jeu).</summary>
|
||||
public int LauncherFilesKept { get; init; }
|
||||
}
|
||||
@@ -5,7 +5,7 @@ namespace GameLauncher.Core.SelfUpdate;
|
||||
|
||||
/// <summary>
|
||||
/// Préférences de mise à jour du launcher lui-même, communes à tous les jeux :
|
||||
/// %AppData%\<Publisher>\<LauncherId>\self-update.json
|
||||
/// %AppData%\<Publisher>\GameLauncher\self-update.json (voir LauncherCatalog.DataDirectory)
|
||||
/// </summary>
|
||||
public sealed class SelfUpdateSettings
|
||||
{
|
||||
|
||||
@@ -428,10 +428,15 @@ public sealed class GameInstaller
|
||||
/// <summary>
|
||||
/// Désinstalle le jeu. Par défaut, seuls les fichiers installés par le launcher sont supprimés
|
||||
/// (les sauvegardes/configurations créées par le jeu sont conservées).
|
||||
/// Avec <paramref name="deleteEverything"/>, le dossier entier est supprimé.
|
||||
/// Avec <paramref name="deleteEverything"/>, tout le contenu du dossier est supprimé.
|
||||
///
|
||||
/// Les fichiers de <paramref name="keepFiles"/> (le launcher, s'il est dans le dossier du jeu) ne
|
||||
/// sont jamais supprimés. Un fichier bloqué (jeu mal fermé, antivirus…) n'interrompt pas la
|
||||
/// désinstallation : tout ce qui peut l'être est supprimé, le reste est signalé (LockedFiles).
|
||||
/// </summary>
|
||||
public Task<UninstallResult> UninstallAsync(string installDir, GameManifest? manifest, bool deleteEverything,
|
||||
IProgress<OperationProgress>? progress, CancellationToken ct)
|
||||
IProgress<OperationProgress>? progress, CancellationToken ct,
|
||||
IReadOnlyCollection<string>? keepFiles = null)
|
||||
{
|
||||
return Task.Run(() =>
|
||||
{
|
||||
@@ -439,15 +444,30 @@ public sealed class GameInstaller
|
||||
Log.Info($"Désinstallation de {installDir} (tout supprimer : {deleteEverything})");
|
||||
if (!Directory.Exists(installDir)) return new UninstallResult(0, 0, true);
|
||||
|
||||
var keep = new HashSet<string>(
|
||||
(keepFiles ?? Array.Empty<string>()).Where(f => LauncherSelfFiles.IsInside(f, installDir)).Select(Path.GetFullPath),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
if (keep.Count > 0) Log.Info($"Fichiers du launcher conservés dans le dossier du jeu : {string.Join(", ", keep.Where(File.Exists))}");
|
||||
|
||||
if (deleteEverything)
|
||||
{
|
||||
if (PathHelper.IsDangerousDirectory(installDir))
|
||||
throw new InvalidOperationException($"Le dossier « {installDir} » est protégé : suppression complète refusée.");
|
||||
|
||||
progress?.Report(new OperationProgress("Suppression du dossier du jeu", installDir, 0, true));
|
||||
var count = Directory.EnumerateFiles(installDir, "*", SearchOption.AllDirectories).Count();
|
||||
FileUtil.DeleteDirectoryRecursive(installDir);
|
||||
return new UninstallResult(count, 0, true);
|
||||
// Fichier par fichier : le launcher est épargné et un fichier bloqué n'arrête pas le reste.
|
||||
var deletion = FileUtil.DeleteDirectoryContents(installDir, keep, (index, total) =>
|
||||
{
|
||||
if (index % 25 == 1 || index == total)
|
||||
progress?.Report(new OperationProgress("Suppression du dossier du jeu",
|
||||
$"{index}/{total} fichiers", index * 100.0 / total));
|
||||
}, ct);
|
||||
Log.Info($"Désinstallation : {deletion.DeletedFiles} fichier(s) supprimé(s), {deletion.LockedFiles.Count} bloqué(s), " +
|
||||
$"{deletion.KeptFiles} fichier(s) du launcher conservé(s).");
|
||||
return new UninstallResult(deletion.DeletedFiles, 0, deletion.FolderRemoved)
|
||||
{
|
||||
LockedFiles = deletion.LockedFiles,
|
||||
LauncherFilesKept = deletion.KeptFiles
|
||||
};
|
||||
}
|
||||
|
||||
var paths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -457,6 +477,7 @@ public sealed class GameInstaller
|
||||
|
||||
var deleted = 0;
|
||||
var index = 0;
|
||||
var locked = new List<string>();
|
||||
foreach (var relative in paths)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
@@ -468,28 +489,29 @@ public sealed class GameInstaller
|
||||
}
|
||||
|
||||
string full;
|
||||
try { full = PathHelper.ResolveSafe(installDir, relative); }
|
||||
try { full = Path.GetFullPath(PathHelper.ResolveSafe(installDir, relative)); }
|
||||
catch { continue; }
|
||||
if (File.Exists(full) && FileUtil.TryDeleteFile(full)) deleted++;
|
||||
if (!File.Exists(full) || keep.Contains(full)) continue;
|
||||
if (FileUtil.TryDeleteFile(full)) deleted++;
|
||||
else locked.Add(full);
|
||||
}
|
||||
|
||||
try { FileUtil.DeleteDirectoryRecursive(InstallStateStore.GetMetaDirectory(installDir)); }
|
||||
catch (Exception ex) { Log.Warn($"Suppression de .launcher impossible : {ex.Message}"); }
|
||||
|
||||
FileUtil.DeleteEmptyDirectories(installDir);
|
||||
var remaining = Directory.EnumerateFiles(installDir, "*", SearchOption.AllDirectories).Count();
|
||||
var removed = false;
|
||||
if (PathHelper.IsDirectoryEmpty(installDir))
|
||||
{
|
||||
try { Directory.Delete(installDir); removed = true; }
|
||||
catch (Exception ex) { Log.Warn($"Suppression du dossier impossible : {ex.Message}"); }
|
||||
}
|
||||
var remainingFiles = Directory.EnumerateFiles(installDir, "*", FileUtil.AllFiles).Select(Path.GetFullPath).ToList();
|
||||
var launcherKept = remainingFiles.Count(keep.Contains);
|
||||
var personal = remainingFiles.Count - launcherKept - locked.Count(remainingFiles.Contains);
|
||||
var removed = FileUtil.TryRemoveEmptyDirectory(installDir);
|
||||
|
||||
Log.Info($"Désinstallation : {deleted} fichier(s) supprimé(s), {remaining} conservé(s).");
|
||||
return new UninstallResult(deleted, remaining, removed);
|
||||
Log.Info($"Désinstallation : {deleted} fichier(s) supprimé(s), {personal} fichier(s) personnel(s) conservé(s), " +
|
||||
$"{locked.Count} bloqué(s), {launcherKept} fichier(s) du launcher conservé(s).");
|
||||
return new UninstallResult(deleted, Math.Max(0, personal), removed) { LockedFiles = locked, LauncherFilesKept = launcherKept };
|
||||
}, ct);
|
||||
}
|
||||
|
||||
|
||||
// ------------------------------------------------------------------ Utilitaires
|
||||
|
||||
private static void RemoveObsoleteFiles(string installDir, IEnumerable<string> oldPaths, GameManifest manifest, InstallState state)
|
||||
|
||||
@@ -1,7 +1,76 @@
|
||||
namespace GameLauncher.Core.Utils;
|
||||
|
||||
/// <summary>Résultat de <see cref="FileUtil.DeleteDirectoryContents"/>.</summary>
|
||||
/// <param name="DeletedFiles">Fichiers supprimés.</param>
|
||||
/// <param name="LockedFiles">Fichiers qui n'ont pas pu être supprimés (fichier ouvert, antivirus…).</param>
|
||||
/// <param name="KeptFiles">Fichiers conservés volontairement (fichiers du launcher).</param>
|
||||
/// <param name="FolderRemoved">Le dossier a disparu (il était vide à la fin).</param>
|
||||
public sealed record DirectoryDeletion(int DeletedFiles, IReadOnlyList<string> LockedFiles, int KeptFiles, bool FolderRemoved);
|
||||
|
||||
public static class FileUtil
|
||||
{
|
||||
/// <summary>Tous les fichiers, y compris cachés (ex. dossier .launcher) ; dossiers inaccessibles ignorés.</summary>
|
||||
public static readonly EnumerationOptions AllFiles = new()
|
||||
{
|
||||
RecurseSubdirectories = true,
|
||||
IgnoreInaccessible = true,
|
||||
AttributesToSkip = 0
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Supprime le contenu d'un dossier fichier par fichier, puis les sous-dossiers vides, puis le
|
||||
/// dossier lui-même s'il est vide. Utilisé par la désinstallation (dossier du jeu, sauvegardes,
|
||||
/// journaux) :
|
||||
/// • les fichiers de <paramref name="keepFiles"/> (le launcher, s'il se trouve dans ce dossier)
|
||||
/// ne sont jamais supprimés ;
|
||||
/// • un fichier bloqué n'interrompt pas la suppression : il est seulement signalé.
|
||||
/// </summary>
|
||||
/// <param name="onProgress">(index, total) : avancement, facultatif.</param>
|
||||
public static DirectoryDeletion DeleteDirectoryContents(string directory, IReadOnlyCollection<string>? keepFiles = null,
|
||||
Action<int, int>? onProgress = null, CancellationToken ct = default)
|
||||
{
|
||||
directory = Path.GetFullPath(directory);
|
||||
if (!Directory.Exists(directory)) return new DirectoryDeletion(0, Array.Empty<string>(), 0, true);
|
||||
|
||||
var keep = new HashSet<string>(
|
||||
(keepFiles ?? Array.Empty<string>()).Where(f => LauncherSelfFiles.IsInside(f, directory)).Select(Path.GetFullPath),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var files = Directory.EnumerateFiles(directory, "*", AllFiles).ToList();
|
||||
var locked = new List<string>();
|
||||
int deleted = 0, kept = 0;
|
||||
for (var i = 0; i < files.Count; i++)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
onProgress?.Invoke(i + 1, files.Count);
|
||||
|
||||
var file = Path.GetFullPath(files[i]);
|
||||
if (keep.Contains(file)) { kept++; continue; }
|
||||
if (TryDeleteFile(file)) deleted++;
|
||||
else locked.Add(file);
|
||||
}
|
||||
|
||||
DeleteEmptyDirectories(directory);
|
||||
return new DirectoryDeletion(deleted, locked, kept, TryRemoveEmptyDirectory(directory));
|
||||
}
|
||||
|
||||
/// <summary>Supprime le dossier s'il est vide. Vrai s'il n'existe plus.</summary>
|
||||
public static bool TryRemoveEmptyDirectory(string directory)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(directory)) return true;
|
||||
if (Directory.EnumerateFileSystemEntries(directory).Any()) return false;
|
||||
Directory.Delete(directory);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
Log.Warn($"Suppression du dossier impossible ({directory}) : {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Écrit un fichier de façon atomique (fichier temporaire puis remplacement).</summary>
|
||||
public static void WriteAllTextAtomic(string path, string content)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Reflection;
|
||||
|
||||
namespace GameLauncher.Core.Utils;
|
||||
|
||||
/// <summary>
|
||||
/// Fichiers du launcher lui-même, à conserver quand il se trouve dans le dossier d'un jeu que l'on
|
||||
/// désinstalle (option « Supprimer aussi… ») : un exécutable en cours d'exécution ne peut de toute
|
||||
/// façon pas être supprimé, et il sert encore (autres jeux, réinstallation).
|
||||
/// </summary>
|
||||
public static class LauncherSelfFiles
|
||||
{
|
||||
/// <summary>
|
||||
/// Exécutable du launcher, launcher.json à côté, fichiers temporaires de sa mise à jour
|
||||
/// (GameLauncher.exe.old / .download / .replaced / .failed), et en version de développement
|
||||
/// (plusieurs fichiers) les bibliothèques chargées depuis son dossier.
|
||||
/// </summary>
|
||||
/// <returns>Chemins complets (comparaison sans tenir compte de la casse).</returns>
|
||||
public static IReadOnlyCollection<string> Get(string launcherExePath)
|
||||
{
|
||||
var files = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
string exe;
|
||||
try { exe = Path.GetFullPath(launcherExePath); }
|
||||
catch { return files; }
|
||||
var directory = Path.GetDirectoryName(exe);
|
||||
if (string.IsNullOrEmpty(directory)) return files;
|
||||
|
||||
files.Add(exe);
|
||||
files.Add(Path.Combine(directory, "launcher.json"));
|
||||
|
||||
// GameLauncher.exe.old, GameLauncher.exe.1.2.2.download… et, en développement, GameLauncher.dll,
|
||||
// GameLauncher.Core.dll, GameLauncher.deps.json, GameLauncher.runtimeconfig.json, .pdb…
|
||||
var entryName = Assembly.GetEntryAssembly()?.GetName().Name ?? Path.GetFileNameWithoutExtension(exe);
|
||||
foreach (var pattern in new[] { Path.GetFileName(exe) + ".*", entryName + ".*" })
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var file in Directory.EnumerateFiles(directory, pattern)) files.Add(Path.GetFullPath(file));
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
Log.Warn($"Liste des fichiers du launcher incomplète : {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// Version de développement : bibliothèques réellement chargées depuis le dossier du launcher.
|
||||
// (Version publiée « fichier unique » : Location est vide, rien à ajouter.)
|
||||
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
if (assembly.IsDynamic) continue;
|
||||
string location;
|
||||
try { location = assembly.Location; }
|
||||
catch { continue; }
|
||||
if (string.IsNullOrEmpty(location) || !IsInside(location, directory)) continue;
|
||||
var full = Path.GetFullPath(location);
|
||||
files.Add(full);
|
||||
files.Add(Path.ChangeExtension(full, ".pdb"));
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/// <summary>Vrai si <paramref name="path"/> est <paramref name="directory"/> ou se trouve dedans (à toute profondeur).</summary>
|
||||
public static bool IsInside(string path, string directory)
|
||||
{
|
||||
try
|
||||
{
|
||||
var full = Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
var root = Path.GetFullPath(directory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
return string.Equals(full, root, StringComparison.OrdinalIgnoreCase) ||
|
||||
full.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,9 @@ public partial class App : Application, ILauncherRestartHost
|
||||
private bool _ownsMutex;
|
||||
private string? _mutexName;
|
||||
private InstanceChannel? _instanceChannel;
|
||||
private TrayIcon? _trayIcon;
|
||||
private bool _migrateLegacyStorage;
|
||||
private WindowService? _windowService;
|
||||
private LauncherCatalog? _catalog;
|
||||
private CommandLine? _args;
|
||||
private LauncherViewModel? _launcher;
|
||||
@@ -51,6 +54,13 @@ public partial class App : Application, ILauncherRestartHost
|
||||
SynchronizationContext.SetSynchronizationContext(new DispatcherSynchronizationContext(Dispatcher));
|
||||
|
||||
DispatcherUnhandledException += OnDispatcherUnhandledException;
|
||||
|
||||
// Fermeture de session / arrêt de Windows : le launcher se ferme vraiment
|
||||
// (pas de réduction dans la zone de notification, pas de confirmation bloquante).
|
||||
SessionEnding += (_, _) =>
|
||||
{
|
||||
if (_launcher is not null) _launcher.IsExitConfirmed = true;
|
||||
};
|
||||
TaskScheduler.UnobservedTaskException += (_, args) =>
|
||||
{
|
||||
Log.Error("Tâche non observée", args.Exception);
|
||||
@@ -75,7 +85,20 @@ public partial class App : Application, ILauncherRestartHost
|
||||
return;
|
||||
}
|
||||
|
||||
// Données du launcher : %AppData%\<Publisher>\GameLauncher et HKCU\Software\<Publisher>\GameLauncher.
|
||||
// Les emplacements des versions précédentes (EZRLauncher, HKCU\Software\<Publisher>\<GameId>) y sont
|
||||
// déplacés, seulement si aucun autre launcher n'est ouvert : jamais pendant le démarrage qui suit
|
||||
// une mise à jour du launcher (l'ancienne version, encore ouverte, peut devoir être remise en place).
|
||||
_migrateLegacyStorage = args.CaptureDirectory is null && args.UpdatedFromPid is null &&
|
||||
!IsInstanceRunning(catalog.SingleInstanceName);
|
||||
var storageNotes = catalog.PrepareStorage(_migrateLegacyStorage);
|
||||
|
||||
Log.Init(Path.Combine(catalog.DataDirectory, "logs"));
|
||||
foreach (var note in storageNotes)
|
||||
{
|
||||
if (note.StartsWith("! ", StringComparison.Ordinal)) Log.Warn(note[2..]);
|
||||
else Log.Info(note);
|
||||
}
|
||||
_catalog = catalog;
|
||||
|
||||
_launcherPath = Environment.ProcessPath ?? Path.Combine(AppContext.BaseDirectory, "GameLauncher.exe");
|
||||
@@ -116,7 +139,9 @@ public partial class App : Application, ILauncherRestartHost
|
||||
return;
|
||||
}
|
||||
|
||||
ModernDialog.ShowMessage(null, catalog.LauncherName, "Le launcher est déjà ouvert.", DialogKind.Info);
|
||||
ModernDialog.ShowMessage(null, catalog.LauncherName,
|
||||
"Le launcher est déjà ouvert.\n\nS'il n'est pas visible, il est peut-être réduit dans la zone de notification : " +
|
||||
"cliquez sur son icône près de l'horloge (flèche « Afficher les icônes cachées »).", DialogKind.Info);
|
||||
Shutdown(0);
|
||||
return;
|
||||
}
|
||||
@@ -204,8 +229,34 @@ public partial class App : Application, ILauncherRestartHost
|
||||
var window = new Views.MainWindow();
|
||||
MainWindow = window;
|
||||
_mainWindow = window;
|
||||
var windowService = new WindowService(window);
|
||||
|
||||
// Icône de la zone de notification : affichée seulement quand la fenêtre est masquée
|
||||
// (croix avec l'option correspondante, ou pendant la partie). Pas en mode --capture.
|
||||
TrayIcon? tray = null;
|
||||
if (!readOnly)
|
||||
{
|
||||
try
|
||||
{
|
||||
tray = new TrayIcon(launcherPath, catalog.LauncherName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("Icône de la zone de notification indisponible", ex);
|
||||
}
|
||||
}
|
||||
_trayIcon = tray;
|
||||
var windowService = new WindowService(window, tray);
|
||||
_windowService = windowService;
|
||||
var dialogService = new DialogService(window);
|
||||
if (tray is not null)
|
||||
{
|
||||
tray.OpenRequested += windowService.Restore;
|
||||
tray.ExitRequested += () => _launcher?.Quit();
|
||||
window.IsVisibleChanged += (_, e) =>
|
||||
{
|
||||
if (e.NewValue is true) tray.Hide(); // fenêtre réaffichée : l'icône n'a plus lieu d'être
|
||||
};
|
||||
}
|
||||
|
||||
// Préférences communes à tous les jeux (roue crantée). Premier démarrage après la séparation
|
||||
// des paramètres : les réglages de lancement sont repris de ceux du jeu affiché par défaut.
|
||||
@@ -213,24 +264,27 @@ public partial class App : Application, ILauncherRestartHost
|
||||
var migrationSource = catalog.Find(requestedGameId) ?? catalog.Find(catalog.DefaultGameId) ?? catalog.Games[0];
|
||||
var preferences = readOnly && !File.Exists(preferencesPath)
|
||||
? new LauncherPreferences() // --capture : aucune écriture
|
||||
: LauncherPreferences.Load(preferencesPath, LauncherSettings.Load(LauncherSettings.GetDefaultPath(migrationSource)));
|
||||
: LauncherPreferences.Load(preferencesPath, LauncherSettings.Load(catalog.GetGameSettingsPath(migrationSource)));
|
||||
|
||||
var launcher = new LauncherViewModel(catalog, windowService, dialogService, preferences, version, _updates);
|
||||
_launcher = launcher;
|
||||
|
||||
foreach (var config in catalog.Games)
|
||||
{
|
||||
var settingsPath = LauncherSettings.GetDefaultPath(config);
|
||||
var settingsPath = catalog.GetGameSettingsPath(config); // GameLauncher\Games\<GameId>\settings.json
|
||||
var settings = LauncherSettings.Load(settingsPath);
|
||||
var installer = new GameInstaller(_http!);
|
||||
var manifestClient = new ManifestClient(_http!, new Uri(config.ManifestUrl));
|
||||
|
||||
// Si le launcher a été déplacé depuis l'installation du jeu, le bouton « Désinstaller »
|
||||
// de Windows doit pointer vers son nouvel emplacement. (Pas en mode --capture.)
|
||||
var registry = new RegistryService(config, launcherPath);
|
||||
var shortcuts = new ShortcutService(config, launcherPath);
|
||||
if (!readOnly)
|
||||
// Seul le launcher réellement ouvert (verrou d'instance unique obtenu) écrit dans le registre :
|
||||
// un second launcher s'arrête avant d'arriver ici (« Le launcher est déjà ouvert »).
|
||||
var registry = new RegistryService(config, launcherPath, catalog.DataFolderName, ownsLauncherInstance: _ownsMutex);
|
||||
var shortcuts = new ShortcutService(config, launcherPath, registry); // icône : exécutable du jeu (registre)
|
||||
if (!readOnly && _ownsMutex)
|
||||
{
|
||||
if (_migrateLegacyStorage) registry.MigrateLegacyKey(); // HKCU\Software\<Publisher>\<GameId> → GameLauncher\Games\<GameId>
|
||||
registry.RefreshLauncherPath();
|
||||
shortcuts.RefreshDesktopShortcut(); // ex. après une mise à jour qui a renommé l'exe en GameLauncher.exe
|
||||
}
|
||||
@@ -252,7 +306,8 @@ public partial class App : Application, ILauncherRestartHost
|
||||
gameDialogs,
|
||||
gameWindow,
|
||||
version,
|
||||
_updates)
|
||||
_updates,
|
||||
catalog.DataDirectory) // jamais supprimé par « Supprimer aussi les sauvegardes… »
|
||||
{
|
||||
IconImage = ThemeManager.LoadImage(config.Icon, 160),
|
||||
TileImage = ThemeManager.Peek(theme, "BackgroundImage", config.Background) as ImageSource,
|
||||
@@ -290,7 +345,7 @@ public partial class App : Application, ILauncherRestartHost
|
||||
|
||||
// Paramètres du jeu, dans le thème du jeu (vérifie aussi les boîtes de dialogue).
|
||||
var info = new GameSettingsInfo(game.GameName, game.InstallPath, game.InstalledVersionText,
|
||||
game.LatestVersionText, game.InstallSizeText);
|
||||
game.LatestVersionText, game.InstallSizeText, game.Config.GameLogsDirectory, game.Config.GameDataDirectory);
|
||||
await CaptureSettingsAsync(window, new GameSettingsViewModel(new LauncherSettings(), info, new WindowService(window)),
|
||||
Path.Combine(directory, $"{game.GameId}-parametres.png"));
|
||||
}
|
||||
@@ -326,6 +381,8 @@ public partial class App : Application, ILauncherRestartHost
|
||||
{
|
||||
_instanceChannel?.Dispose();
|
||||
_instanceChannel = null;
|
||||
_trayIcon?.Dispose(); // sinon l'icône resterait affichée jusqu'au survol
|
||||
_trayIcon = null;
|
||||
_launcher?.Dispose();
|
||||
_http?.Dispose();
|
||||
ReleaseSingleInstance();
|
||||
@@ -335,6 +392,21 @@ public partial class App : Application, ILauncherRestartHost
|
||||
|
||||
// ------------------------------------------------------------------ Instance unique
|
||||
|
||||
/// <summary>Vrai si un autre launcher (même configuration) est ouvert : son verrou d'instance unique existe.</summary>
|
||||
private static bool IsInstanceRunning(string mutexName)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Mutex.TryOpenExisting(mutexName, out var existing)) return false;
|
||||
existing.Dispose();
|
||||
return true;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return true; // verrou existant, mais inaccessible : par prudence, considéré comme ouvert
|
||||
}
|
||||
}
|
||||
|
||||
private bool AcquireSingleInstance(TimeSpan wait)
|
||||
{
|
||||
if (_mutexName is null) return true;
|
||||
@@ -373,6 +445,9 @@ public partial class App : Application, ILauncherRestartHost
|
||||
if (_launcher is null || _mainWindow is null) return;
|
||||
if (_updates?.IsInstalling == true) return; // le launcher redémarre dans sa nouvelle version
|
||||
|
||||
// Launcher masqué dans la zone de notification : la fenêtre est d'abord réaffichée.
|
||||
if (!_mainWindow.IsVisible) _windowService?.Restore();
|
||||
|
||||
// Une boîte de dialogue est ouverte (confirmation, choix de dossier…) : on ne lance rien
|
||||
// derrière elle, la fenêtre revient simplement au premier plan.
|
||||
if (!IsMainWindowInteractive())
|
||||
@@ -529,8 +604,10 @@ public partial class App : Application, ILauncherRestartHost
|
||||
}
|
||||
|
||||
// Sans fenêtre principale visible, le processus resterait ouvert en arrière-plan.
|
||||
// (Sauf pendant la mise à jour du launcher : la fenêtre est masquée volontairement.)
|
||||
if (MainWindow is not { IsVisible: true } && _updates?.IsInstalling != true) Shutdown(1);
|
||||
// (Sauf pendant la mise à jour du launcher, ou réduit dans la zone de notification :
|
||||
// la fenêtre est alors masquée volontairement.)
|
||||
if (MainWindow is not { IsVisible: true } && _updates?.IsInstalling != true && _trayIcon?.IsVisible != true)
|
||||
Shutdown(1);
|
||||
}
|
||||
|
||||
/// <summary>Lecture des arguments de la ligne de commande.</summary>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
(1.0.0 → 1.0.1 → 1.1.0…). Les launchers des joueurs ne se mettent à jour que vers
|
||||
une version STRICTEMENT supérieure à la leur.
|
||||
-->
|
||||
<Version>1.2.0</Version>
|
||||
<Version>1.3.0</Version>
|
||||
<Authors>Lanisys</Authors>
|
||||
<Company>LanisysGames</Company>
|
||||
<Copyright>© LanisysGames</Copyright>
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Windows;
|
||||
using System.Windows.Interop;
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Views;
|
||||
|
||||
namespace GameLauncher.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Icône du launcher dans la zone de notification de Windows (près de l'horloge), affichée
|
||||
/// pendant que la fenêtre est masquée :
|
||||
/// • clic gauche : réouvre le launcher ;
|
||||
/// • clic droit : menu « Ouvrir » / « Quitter » (<see cref="TrayMenuWindow"/>), dans le style des
|
||||
/// menus du launcher.
|
||||
///
|
||||
/// API Windows (Shell_NotifyIcon) appelée directement : pas de dépendance à Windows Forms.
|
||||
/// L'icône est recréée si l'Explorateur Windows redémarre (message « TaskbarCreated »).
|
||||
/// </summary>
|
||||
public sealed class TrayIcon : IDisposable
|
||||
{
|
||||
private const int CallbackMessage = 0x8000 + 0x42; // WM_APP + 0x42
|
||||
private const int IconId = 1;
|
||||
|
||||
private readonly HwndSource _source;
|
||||
private readonly int _taskbarCreatedMessage;
|
||||
private readonly IntPtr _icon;
|
||||
private readonly bool _ownsIcon;
|
||||
private TrayMenuWindow? _menu;
|
||||
private string _toolTip;
|
||||
private bool _isVisible;
|
||||
private bool _disposed;
|
||||
|
||||
/// <param name="executablePath">Exécutable dont l'icône est affichée (le launcher).</param>
|
||||
/// <param name="toolTip">Texte affiché au survol de l'icône.</param>
|
||||
public TrayIcon(string executablePath, string toolTip)
|
||||
{
|
||||
_toolTip = toolTip;
|
||||
|
||||
// Fenêtre invisible qui reçoit les clics sur l'icône. Fenêtre de premier niveau (et non
|
||||
// « message-only ») : elle doit recevoir le message diffusé quand l'Explorateur redémarre.
|
||||
_source = new HwndSource(new HwndSourceParameters("GameLauncher.TrayIcon")
|
||||
{
|
||||
Width = 0,
|
||||
Height = 0,
|
||||
WindowStyle = 0, // ni WS_VISIBLE, ni bordure
|
||||
ExtendedWindowStyle = 0x80 // WS_EX_TOOLWINDOW : absente de la barre des tâches et d'Alt+Tab
|
||||
});
|
||||
_source.AddHook(WndProc);
|
||||
_taskbarCreatedMessage = RegisterWindowMessage("TaskbarCreated");
|
||||
|
||||
(_icon, _ownsIcon) = LoadIcon(executablePath);
|
||||
}
|
||||
|
||||
/// <summary>Clic gauche sur l'icône, ou « Ouvrir » dans son menu.</summary>
|
||||
public event Action? OpenRequested;
|
||||
|
||||
/// <summary>« Quitter » dans le menu de l'icône.</summary>
|
||||
public event Action? ExitRequested;
|
||||
|
||||
public bool IsVisible => _isVisible;
|
||||
|
||||
/// <summary>Texte affiché au survol (127 caractères au plus).</summary>
|
||||
public string ToolTip
|
||||
{
|
||||
get => _toolTip;
|
||||
set
|
||||
{
|
||||
_toolTip = value;
|
||||
if (_isVisible) Notify(NIM_MODIFY);
|
||||
}
|
||||
}
|
||||
|
||||
public void Show()
|
||||
{
|
||||
if (_disposed || _isVisible) return;
|
||||
_isVisible = Notify(NIM_ADD);
|
||||
if (!_isVisible) Log.Warn("Icône de la zone de notification : ajout impossible.");
|
||||
}
|
||||
|
||||
public void Hide()
|
||||
{
|
||||
CloseMenu();
|
||||
if (!_isVisible) return;
|
||||
Notify(NIM_DELETE);
|
||||
_isVisible = false;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
Hide();
|
||||
_disposed = true;
|
||||
_source.RemoveHook(WndProc);
|
||||
_source.Dispose();
|
||||
if (_ownsIcon && _icon != IntPtr.Zero) DestroyIcon(_icon);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Messages de l'icône
|
||||
|
||||
private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
|
||||
{
|
||||
if (msg == CallbackMessage)
|
||||
{
|
||||
switch (unchecked((int)lParam.ToInt64()) & 0xFFFF)
|
||||
{
|
||||
case WM_LBUTTONUP:
|
||||
CloseMenu();
|
||||
OpenRequested?.Invoke();
|
||||
handled = true;
|
||||
break;
|
||||
case WM_RBUTTONUP:
|
||||
case WM_CONTEXTMENU:
|
||||
ShowMenu();
|
||||
handled = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
else if (msg == _taskbarCreatedMessage && _taskbarCreatedMessage != 0 && _isVisible)
|
||||
{
|
||||
// L'Explorateur a redémarré : la zone de notification est vide, on se réinscrit.
|
||||
_isVisible = Notify(NIM_ADD);
|
||||
}
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ouvre le menu près du curseur (un second clic droit le referme). Le menu est une petite
|
||||
/// fenêtre et non un ContextMenu WPF : launcher masqué, un ContextMenu ne peut pas capturer la
|
||||
/// souris et se referme aussitôt ouvert.
|
||||
/// </summary>
|
||||
private void ShowMenu()
|
||||
{
|
||||
if (_menu is not null)
|
||||
{
|
||||
CloseMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Position du curseur (pixels) convertie en unités WPF.
|
||||
GetCursorPos(out var cursor);
|
||||
var point = new Point(cursor.X, cursor.Y);
|
||||
if (_source.CompositionTarget is { } target) point = target.TransformFromDevice.Transform(point);
|
||||
|
||||
var menu = new TrayMenuWindow();
|
||||
menu.OpenClicked += () => OpenRequested?.Invoke();
|
||||
menu.ExitClicked += () => ExitRequested?.Invoke();
|
||||
menu.Closed += (_, _) =>
|
||||
{
|
||||
if (ReferenceEquals(_menu, menu)) _menu = null;
|
||||
};
|
||||
_menu = menu;
|
||||
menu.ShowAt(point);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("Menu de l'icône de la zone de notification", ex);
|
||||
CloseMenu();
|
||||
}
|
||||
}
|
||||
|
||||
private void CloseMenu()
|
||||
{
|
||||
var menu = _menu;
|
||||
_menu = null;
|
||||
menu?.CloseMenu();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Shell_NotifyIcon
|
||||
|
||||
private bool Notify(int message)
|
||||
{
|
||||
var data = new NOTIFYICONDATAW
|
||||
{
|
||||
cbSize = Marshal.SizeOf<NOTIFYICONDATAW>(),
|
||||
hWnd = _source.Handle,
|
||||
uID = IconId,
|
||||
uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP,
|
||||
uCallbackMessage = CallbackMessage,
|
||||
hIcon = _icon,
|
||||
szTip = _toolTip.Length > 127 ? _toolTip[..127] : _toolTip
|
||||
};
|
||||
return Shell_NotifyIconW(message, ref data);
|
||||
}
|
||||
|
||||
/// <summary>Icône de l'exécutable à la taille des petites icônes (selon la mise à l'échelle de l'écran).</summary>
|
||||
private static (IntPtr Icon, bool Owned) LoadIcon(string executablePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var size = Math.Max(16, GetSystemMetrics(SM_CXSMICON));
|
||||
var sizes = (uint)((size << 16) | size);
|
||||
if (SHDefExtractIconW(executablePath, 0, 0, out var icon, IntPtr.Zero, sizes) == 0 && icon != IntPtr.Zero)
|
||||
return (icon, true);
|
||||
}
|
||||
catch (Exception ex) when (ex is Win32Exception or DllNotFoundException or EntryPointNotFoundException)
|
||||
{
|
||||
Log.Warn($"Icône du launcher illisible : {ex.Message}");
|
||||
}
|
||||
return (LoadIconW(IntPtr.Zero, new IntPtr(IDI_APPLICATION)), false); // icône système partagée
|
||||
}
|
||||
|
||||
private const int NIM_ADD = 0, NIM_MODIFY = 1, NIM_DELETE = 2;
|
||||
private const int NIF_MESSAGE = 0x1, NIF_ICON = 0x2, NIF_TIP = 0x4;
|
||||
private const int WM_CONTEXTMENU = 0x007B, WM_LBUTTONUP = 0x0202, WM_RBUTTONUP = 0x0205;
|
||||
private const int SM_CXSMICON = 49;
|
||||
private const int IDI_APPLICATION = 32512;
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
private struct NOTIFYICONDATAW
|
||||
{
|
||||
public int cbSize;
|
||||
public IntPtr hWnd;
|
||||
public int uID;
|
||||
public int uFlags;
|
||||
public int uCallbackMessage;
|
||||
public IntPtr hIcon;
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] public string szTip;
|
||||
public int dwState;
|
||||
public int dwStateMask;
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] public string szInfo;
|
||||
public int uTimeoutOrVersion;
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)] public string szInfoTitle;
|
||||
public int dwInfoFlags;
|
||||
public Guid guidItem;
|
||||
public IntPtr hBalloonIcon;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct POINT
|
||||
{
|
||||
public int X;
|
||||
public int Y;
|
||||
}
|
||||
|
||||
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool Shell_NotifyIconW(int message, ref NOTIFYICONDATAW data);
|
||||
|
||||
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern int SHDefExtractIconW(string iconFile, int index, uint flags, out IntPtr large, IntPtr small, uint iconSize);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern IntPtr LoadIconW(IntPtr instance, IntPtr iconName);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool DestroyIcon(IntPtr icon);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
private static extern int RegisterWindowMessage(string message);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool GetCursorPos(out POINT point);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern int GetSystemMetrics(int index);
|
||||
}
|
||||
@@ -9,13 +9,37 @@ namespace GameLauncher.Platform;
|
||||
public sealed class WindowService : IWindowService
|
||||
{
|
||||
private readonly Window _window;
|
||||
private readonly TrayIcon? _tray;
|
||||
|
||||
public WindowService(Window window) => _window = window;
|
||||
/// <param name="tray">Icône de la zone de notification (absente : « HideToTray » réduit simplement la fenêtre).</param>
|
||||
public WindowService(Window window, TrayIcon? tray = null)
|
||||
{
|
||||
_window = window;
|
||||
_tray = tray;
|
||||
}
|
||||
|
||||
public void Minimize() => _window.WindowState = WindowState.Minimized;
|
||||
|
||||
public void HideToTray()
|
||||
{
|
||||
if (_tray is null)
|
||||
{
|
||||
Minimize();
|
||||
return;
|
||||
}
|
||||
_tray.ToolTip = string.IsNullOrWhiteSpace(_window.Title) ? "Launcher" : _window.Title;
|
||||
_tray.Show();
|
||||
if (!_tray.IsVisible)
|
||||
{
|
||||
Minimize(); // icône refusée par Windows : on ne masque pas une fenêtre qu'on ne pourrait plus rouvrir
|
||||
return;
|
||||
}
|
||||
_window.Hide();
|
||||
}
|
||||
|
||||
public void Restore()
|
||||
{
|
||||
if (!_window.IsVisible) _window.Show(); // l'icône de la zone de notification disparaît (voir App)
|
||||
if (_window.WindowState == WindowState.Minimized) _window.WindowState = WindowState.Normal;
|
||||
_window.Activate();
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ public sealed class GameWindowService : IWindowService
|
||||
}
|
||||
|
||||
public void Minimize() => _inner.Minimize();
|
||||
public void HideToTray() => _inner.HideToTray();
|
||||
public void Restore() => _inner.Restore();
|
||||
public void OpenFolder(string path) => _inner.OpenFolder(path);
|
||||
public void OpenUrl(string url) => _inner.OpenUrl(url);
|
||||
|
||||
@@ -39,6 +39,11 @@ public interface IDialogService
|
||||
public interface IWindowService
|
||||
{
|
||||
void Minimize();
|
||||
|
||||
/// <summary>Masque la fenêtre et affiche l'icône du launcher dans la zone de notification.</summary>
|
||||
void HideToTray();
|
||||
|
||||
/// <summary>Réaffiche la fenêtre (réduite ou dans la zone de notification) et l'active.</summary>
|
||||
void Restore();
|
||||
void Shutdown();
|
||||
void OpenFolder(string path);
|
||||
@@ -59,6 +64,12 @@ public interface IRegistryService
|
||||
/// </summary>
|
||||
void RefreshLauncherPath();
|
||||
void Delete();
|
||||
|
||||
/// <summary>
|
||||
/// Réglages enregistrés par Unity pour le jeu (résolution, plein écran, position de la fenêtre :
|
||||
/// HKCU\Software\<Société>\<Produit>). Option « Supprimer aussi… » de la désinstallation.
|
||||
/// </summary>
|
||||
void DeleteGameUserSettings();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -72,6 +83,9 @@ public interface IRegistryService
|
||||
public sealed record DesktopShortcut(string FilePath, string TargetPath, bool PointsToThisLauncher, bool TargetExists)
|
||||
{
|
||||
public string Name => System.IO.Path.GetFileNameWithoutExtension(FilePath);
|
||||
|
||||
/// <summary>Icône du raccourci (« chemin,index »), telle qu'enregistrée.</summary>
|
||||
public string? IconLocation { get; init; }
|
||||
}
|
||||
|
||||
public interface IShortcutService
|
||||
@@ -79,11 +93,13 @@ public interface IShortcutService
|
||||
/// <summary>Emplacement du launcher en cours d'exécution.</summary>
|
||||
string LauncherPath { get; }
|
||||
|
||||
void CreateDesktopShortcut();
|
||||
/// <summary>Crée le raccourci du Bureau (icône du jeu si installé). Faux en cas d'échec (journalisé).</summary>
|
||||
bool CreateDesktopShortcut();
|
||||
|
||||
/// <summary>
|
||||
/// Démarrage du launcher : les raccourcis de ce jeu dont la cible a disparu (launcher renommé
|
||||
/// ou déplacé) sont redirigés vers le launcher actuel, sans rien demander.
|
||||
/// Sans rien demander : les raccourcis de ce jeu dont la cible a disparu (launcher renommé ou
|
||||
/// déplacé) sont redirigés vers le launcher actuel, et l'icône posée par le launcher devient
|
||||
/// celle du jeu, à son emplacement actuel (démarrage, installation, réparation, localisation…).
|
||||
/// </summary>
|
||||
void RefreshDesktopShortcut();
|
||||
|
||||
|
||||
@@ -11,8 +11,14 @@ namespace GameLauncher.Services;
|
||||
/// Enregistre l'emplacement d'installation du jeu dans le registre (HKEY_CURRENT_USER,
|
||||
/// aucun droit administrateur nécessaire) :
|
||||
///
|
||||
/// • HKCU\Software\<Publisher>\<GameId>
|
||||
/// • HKCU\Software\<Publisher>\GameLauncher
|
||||
/// LauncherPath : emplacement du launcher, une seule fois pour tous les jeux. Écrit uniquement
|
||||
/// par le launcher réellement ouvert (détenteur du verrou d'instance unique) : un second
|
||||
/// launcher, arrêté par « Le launcher est déjà ouvert », ne l'écrase jamais.
|
||||
/// • HKCU\Software\<Publisher>\GameLauncher\Games\<GameId>
|
||||
/// InstallPath, Version, Executable, InstallDate
|
||||
/// (launcher 1.2.1 et antérieurs : HKCU\Software\<Publisher>\<GameId>, déplacée par
|
||||
/// <see cref="MigrateLegacyKey"/> ; lue en attendant si elle n'a pas encore pu l'être)
|
||||
/// • HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\<Publisher>.<GameId>
|
||||
/// entrée visible dans "Paramètres › Applications installées" ; le bouton
|
||||
/// "Désinstaller" de Windows relance le launcher avec l'argument --uninstall <GameId>.
|
||||
@@ -21,24 +27,102 @@ namespace GameLauncher.Services;
|
||||
public sealed class RegistryService : IRegistryService
|
||||
{
|
||||
private const string UninstallRoot = @"Software\Microsoft\Windows\CurrentVersion\Uninstall";
|
||||
private const string LauncherPathValue = "LauncherPath";
|
||||
|
||||
private readonly LauncherConfig _config;
|
||||
private readonly string _launcherPath;
|
||||
private readonly string _dataFolderName;
|
||||
private readonly bool _ownsLauncherInstance;
|
||||
|
||||
public RegistryService(LauncherConfig config, string launcherPath)
|
||||
/// <param name="dataFolderName">Nom de la clé du launcher (« GameLauncher », voir LauncherCatalog.DataFolderName).</param>
|
||||
/// <param name="ownsLauncherInstance">
|
||||
/// Vrai seulement pour le launcher réellement ouvert (verrou d'instance unique obtenu) : lui seul
|
||||
/// écrit l'emplacement du launcher (LauncherPath, bouton « Désinstaller » de Windows).
|
||||
/// </param>
|
||||
public RegistryService(LauncherConfig config, string launcherPath, string dataFolderName, bool ownsLauncherInstance)
|
||||
{
|
||||
_config = config;
|
||||
_launcherPath = launcherPath;
|
||||
_dataFolderName = dataFolderName;
|
||||
_ownsLauncherInstance = ownsLauncherInstance;
|
||||
}
|
||||
|
||||
private string GameKeyPath => $@"Software\{_config.Publisher}\{_config.GameId}";
|
||||
private string PublisherKeyPath => $@"Software\{_config.Publisher}";
|
||||
private string LauncherKeyPath => $@"{PublisherKeyPath}\{_dataFolderName}";
|
||||
private string GamesKeyPath => $@"{LauncherKeyPath}\Games";
|
||||
private string GameKeyPath => $@"{GamesKeyPath}\{_config.GameId}";
|
||||
|
||||
/// <summary>Ancienne clé du jeu (launcher 1.2.1 et antérieurs).</summary>
|
||||
private string LegacyGameKeyPath => $@"{PublisherKeyPath}\{_config.GameId}";
|
||||
|
||||
// Entrée « Applications installées » de Windows : nom inchangé (invisible pour le joueur).
|
||||
private string UninstallKeyPath => $@"{UninstallRoot}\{_config.Publisher}.{_config.GameId}";
|
||||
|
||||
/// <summary>
|
||||
/// Déplace l'ancienne clé HKCU\Software\<Publisher>\<GameId> vers
|
||||
/// HKCU\Software\<Publisher>\GameLauncher\Games\<GameId> : toutes les valeurs sont copiées,
|
||||
/// vérifiées, puis l'ancienne clé est supprimée. Déjà fait ou rien à déplacer : aucun effet.
|
||||
/// En cas d'échec, l'ancienne clé est conservée (et lue par <see cref="Read"/>).
|
||||
/// </summary>
|
||||
public void MigrateLegacyKey()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var legacy = Registry.CurrentUser.OpenSubKey(LegacyGameKeyPath);
|
||||
if (legacy is null) return;
|
||||
|
||||
using (var current = Registry.CurrentUser.OpenSubKey(GameKeyPath))
|
||||
{
|
||||
if (current is not null && current.ValueCount > 0)
|
||||
{
|
||||
// Les deux existent : la nouvelle clé fait foi, l'ancienne est retirée.
|
||||
legacy.Close();
|
||||
Registry.CurrentUser.DeleteSubKeyTree(LegacyGameKeyPath, throwOnMissingSubKey: false);
|
||||
Log.Info($"Ancienne clé de registre retirée (HKCU\\{LegacyGameKeyPath}) : HKCU\\{GameKeyPath} est utilisée.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// LauncherPath n'est plus propre à chaque jeu : il est écrit une seule fois, à la racine (GameLauncher).
|
||||
var names = legacy.GetValueNames()
|
||||
.Where(n => !string.Equals(n, LauncherPathValue, StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
using (var target = Registry.CurrentUser.CreateSubKey(GameKeyPath, writable: true))
|
||||
{
|
||||
foreach (var name in names)
|
||||
{
|
||||
var value = legacy.GetValue(name, null, RegistryValueOptions.DoNotExpandEnvironmentNames);
|
||||
if (value is not null) target.SetValue(name, value, legacy.GetValueKind(name));
|
||||
}
|
||||
if (names.Any(n => target.GetValue(n, null, RegistryValueOptions.DoNotExpandEnvironmentNames) is null))
|
||||
throw new InvalidOperationException("copie incomplète");
|
||||
}
|
||||
|
||||
legacy.Close();
|
||||
Registry.CurrentUser.DeleteSubKeyTree(LegacyGameKeyPath, throwOnMissingSubKey: false);
|
||||
Log.Info($"Registre : HKCU\\{LegacyGameKeyPath} déplacée vers HKCU\\{GameKeyPath}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Non bloquant : Read() lit l'ancienne clé tant que le déplacement n'a pas réussi.
|
||||
Log.Warn($"Déplacement de la clé de registre HKCU\\{LegacyGameKeyPath} impossible : {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Clé du jeu existante : la nouvelle, sinon l'ancienne (pas encore déplacée).</summary>
|
||||
private string ExistingGameKeyPath()
|
||||
{
|
||||
using var current = Registry.CurrentUser.OpenSubKey(GameKeyPath);
|
||||
if (current is not null) return GameKeyPath;
|
||||
using var legacy = Registry.CurrentUser.OpenSubKey(LegacyGameKeyPath);
|
||||
return legacy is not null ? LegacyGameKeyPath : GameKeyPath;
|
||||
}
|
||||
|
||||
public GameRegistration? Read()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var key = Registry.CurrentUser.OpenSubKey(GameKeyPath);
|
||||
using var key = Registry.CurrentUser.OpenSubKey(ExistingGameKeyPath());
|
||||
if (key?.GetValue("InstallPath") is not string path || string.IsNullOrWhiteSpace(path))
|
||||
return null;
|
||||
|
||||
@@ -64,7 +148,18 @@ public sealed class RegistryService : IRegistryService
|
||||
key.SetValue("Executable", registration.Executable, RegistryValueKind.String);
|
||||
key.SetValue("InstallDate", (registration.InstalledAt ?? DateTimeOffset.Now).ToString("o", CultureInfo.InvariantCulture),
|
||||
RegistryValueKind.String);
|
||||
key.SetValue("LauncherPath", _launcherPath, RegistryValueKind.String);
|
||||
key.DeleteValue(LauncherPathValue, throwOnMissingValue: false); // ancien emplacement (par jeu)
|
||||
}
|
||||
WriteLauncherPath();
|
||||
|
||||
// La nouvelle clé est complète : une ancienne clé éventuelle n'a plus lieu d'être.
|
||||
try
|
||||
{
|
||||
Registry.CurrentUser.DeleteSubKeyTree(LegacyGameKeyPath, throwOnMissingSubKey: false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warn($"Suppression de l'ancienne clé HKCU\\{LegacyGameKeyPath} impossible : {ex.Message}");
|
||||
}
|
||||
|
||||
try
|
||||
@@ -93,22 +188,21 @@ public sealed class RegistryService : IRegistryService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Met à jour le chemin du launcher dans les entrées existantes (jeu déjà installé) quand
|
||||
/// l'exécutable a été déplacé. Ne crée rien si le jeu n'a jamais été installé.
|
||||
/// Met à jour l'emplacement du launcher (LauncherPath à la racine de GameLauncher, bouton
|
||||
/// « Désinstaller » de Windows) quand l'exécutable a été déplacé. Ne crée rien si le jeu n'a
|
||||
/// jamais été installé. Sans effet si ce launcher n'est pas le launcher réellement ouvert.
|
||||
/// </summary>
|
||||
public void RefreshLauncherPath()
|
||||
{
|
||||
if (!_ownsLauncherInstance) return;
|
||||
try
|
||||
{
|
||||
using (var key = Registry.CurrentUser.OpenSubKey(GameKeyPath, writable: true))
|
||||
using (var key = Registry.CurrentUser.OpenSubKey(ExistingGameKeyPath(), writable: true))
|
||||
{
|
||||
if (key is null) return;
|
||||
if (!string.Equals(key.GetValue("LauncherPath") as string, _launcherPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
key.SetValue("LauncherPath", _launcherPath, RegistryValueKind.String);
|
||||
Log.Info($"Chemin du launcher mis à jour dans le registre : {_launcherPath}");
|
||||
}
|
||||
key.DeleteValue(LauncherPathValue, throwOnMissingValue: false); // ancien emplacement (par jeu)
|
||||
}
|
||||
WriteLauncherPath();
|
||||
|
||||
using var un = Registry.CurrentUser.OpenSubKey(UninstallKeyPath, writable: true);
|
||||
if (un is null) return;
|
||||
@@ -128,20 +222,54 @@ public sealed class RegistryService : IRegistryService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// LauncherPath, à la racine de GameLauncher (une seule valeur pour tous les jeux). Uniquement par
|
||||
/// le launcher réellement ouvert : vérification explicite, en plus de l'ordre du démarrage (un
|
||||
/// second launcher s'arrête avant toute écriture).
|
||||
/// </summary>
|
||||
private void WriteLauncherPath()
|
||||
{
|
||||
if (!_ownsLauncherInstance)
|
||||
{
|
||||
Log.Warn("LauncherPath non écrit : ce launcher n'est pas le launcher ouvert.");
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
using var root = Registry.CurrentUser.CreateSubKey(LauncherKeyPath, writable: true);
|
||||
if (string.Equals(root.GetValue(LauncherPathValue) as string, _launcherPath, StringComparison.OrdinalIgnoreCase)) return;
|
||||
root.SetValue(LauncherPathValue, _launcherPath, RegistryValueKind.String);
|
||||
Log.Info($"Emplacement du launcher enregistré (HKCU\\{LauncherKeyPath}\\{LauncherPathValue}) : {_launcherPath}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warn($"Enregistrement de l'emplacement du launcher impossible : {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public void Delete()
|
||||
{
|
||||
try
|
||||
{
|
||||
Registry.CurrentUser.DeleteSubKeyTree(GameKeyPath, throwOnMissingSubKey: false);
|
||||
Registry.CurrentUser.DeleteSubKeyTree(LegacyGameKeyPath, throwOnMissingSubKey: false);
|
||||
Registry.CurrentUser.DeleteSubKeyTree(UninstallKeyPath, throwOnMissingSubKey: false);
|
||||
|
||||
// Supprime aussi la clé de l'éditeur si elle est vide.
|
||||
using var publisher = Registry.CurrentUser.OpenSubKey($@"Software\{_config.Publisher}", writable: true);
|
||||
if (publisher is not null && publisher.SubKeyCount == 0 && publisher.ValueCount == 0)
|
||||
// Plus aucun jeu installé : l'emplacement du launcher n'a plus lieu d'être enregistré.
|
||||
using (var games = Registry.CurrentUser.OpenSubKey(GamesKeyPath))
|
||||
{
|
||||
publisher.Close();
|
||||
Registry.CurrentUser.DeleteSubKey($@"Software\{_config.Publisher}", throwOnMissingSubKey: false);
|
||||
if (games is null || games.SubKeyCount == 0)
|
||||
{
|
||||
using var root = Registry.CurrentUser.OpenSubKey(LauncherKeyPath, writable: true);
|
||||
root?.DeleteValue(LauncherPathValue, throwOnMissingValue: false);
|
||||
}
|
||||
}
|
||||
|
||||
// Supprime aussi les clés parentes devenues vides (Games, GameLauncher, éditeur). La clé de
|
||||
// l'éditeur contient aussi celle de Unity (PlayerPrefs du jeu) : elle n'est alors pas vide.
|
||||
DeleteIfEmpty(GamesKeyPath);
|
||||
DeleteIfEmpty(LauncherKeyPath);
|
||||
DeleteIfEmpty(PublisherKeyPath);
|
||||
Log.Info("Entrées de registre supprimées.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -149,4 +277,47 @@ public sealed class RegistryService : IRegistryService
|
||||
Log.Error("Suppression des entrées de registre impossible", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public void DeleteGameUserSettings()
|
||||
{
|
||||
try
|
||||
{
|
||||
var relative = _config.GameDataRelativePath;
|
||||
var segments = relative.Split('\\', StringSplitOptions.RemoveEmptyEntries);
|
||||
var path = $@"Software\{string.Join('\\', segments)}";
|
||||
|
||||
// Par sécurité : au moins « Société\Produit », et jamais la clé du launcher ni une de ses clés parentes.
|
||||
if (segments.Length < 2 ||
|
||||
string.Equals(path, LauncherKeyPath, StringComparison.OrdinalIgnoreCase) ||
|
||||
LauncherKeyPath.StartsWith(path + "\\", StringComparison.OrdinalIgnoreCase) ||
|
||||
path.StartsWith(LauncherKeyPath + "\\", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Log.Warn($"Réglages Unity non supprimés par sécurité : HKCU\\{path}");
|
||||
return;
|
||||
}
|
||||
|
||||
using (var key = Registry.CurrentUser.OpenSubKey(path))
|
||||
{
|
||||
if (key is null) return;
|
||||
}
|
||||
Registry.CurrentUser.DeleteSubKeyTree(path, throwOnMissingSubKey: false);
|
||||
Log.Info($"Réglages Unity du jeu supprimés : HKCU\\{path}");
|
||||
|
||||
// Clé de la société devenue vide (aucun autre jeu, pas de launcher) : supprimée aussi.
|
||||
DeleteIfEmpty(path[..path.LastIndexOf('\\')]);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warn($"Suppression des réglages Unity du jeu impossible : {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void DeleteIfEmpty(string path)
|
||||
{
|
||||
using (var key = Registry.CurrentUser.OpenSubKey(path))
|
||||
{
|
||||
if (key is null || key.SubKeyCount > 0 || key.ValueCount > 0) return;
|
||||
}
|
||||
Registry.CurrentUser.DeleteSubKey(path, throwOnMissingSubKey: false);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,10 @@ namespace GameLauncher.Services;
|
||||
/// nom : il est retrouvé même si le joueur l'a renommé. Si le launcher est déplacé ou renommé,
|
||||
/// ses raccourcis sont redirigés vers le nouvel emplacement (au démarrage si l'ancien exécutable
|
||||
/// a disparu, ou depuis « Réparer »).
|
||||
///
|
||||
/// Icône : celle de l'exécutable du jeu (emplacement lu dans le registre), sinon celle du
|
||||
/// launcher (jeu pas encore installé, exécutable absent). Une icône choisie par le joueur est
|
||||
/// conservée ; celle que le launcher a posée suit le jeu s'il est déplacé.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class ShortcutService : IShortcutService
|
||||
@@ -23,11 +27,14 @@ public sealed class ShortcutService : IShortcutService
|
||||
|
||||
private readonly LauncherConfig _config;
|
||||
private readonly string _launcherPath;
|
||||
private readonly IRegistryService? _registry;
|
||||
|
||||
public ShortcutService(LauncherConfig config, string launcherPath)
|
||||
/// <param name="registry">Emplacement du jeu installé (pour l'icône du raccourci).</param>
|
||||
public ShortcutService(LauncherConfig config, string launcherPath, IRegistryService? registry = null)
|
||||
{
|
||||
_config = config;
|
||||
_launcherPath = Path.GetFullPath(launcherPath);
|
||||
_registry = registry;
|
||||
}
|
||||
|
||||
public string LauncherPath => _launcherPath;
|
||||
@@ -38,25 +45,60 @@ public sealed class ShortcutService : IShortcutService
|
||||
|
||||
private string ShortcutPath => Path.Combine(DesktopDirectory, PathHelper.SanitizeFolderName(_config.GameName) + ".lnk");
|
||||
|
||||
public void CreateDesktopShortcut()
|
||||
/// <summary>
|
||||
/// Exécutable du jeu installé (registre : dossier d'installation + exécutable), s'il existe ;
|
||||
/// sinon null. Chemin absolu vers un .exe uniquement.
|
||||
/// </summary>
|
||||
private string? GameExecutablePath
|
||||
{
|
||||
get
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_registry?.Read() is not { } registration) return null;
|
||||
var exe = Path.GetFullPath(Path.Combine(registration.InstallPath,
|
||||
registration.Executable.Replace('/', Path.DirectorySeparatorChar)));
|
||||
return Path.IsPathRooted(exe) &&
|
||||
string.Equals(Path.GetExtension(exe), ".exe", StringComparison.OrdinalIgnoreCase) &&
|
||||
!exe.Contains(',') && // « chemin,index » : une virgule rendrait l'emplacement ambigu
|
||||
File.Exists(exe)
|
||||
? exe
|
||||
: null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warn($"Exécutable du jeu introuvable pour l'icône du raccourci : {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Icône posée par le launcher : celle du jeu, sinon celle du launcher.</summary>
|
||||
private string DesiredIconLocation => (GameExecutablePath ?? _launcherPath) + ",0";
|
||||
|
||||
public bool CreateDesktopShortcut()
|
||||
{
|
||||
try
|
||||
{
|
||||
var icon = DesiredIconLocation;
|
||||
WithShell(shell => WithLink(shell, ShortcutPath, link =>
|
||||
{
|
||||
link.TargetPath = _launcherPath;
|
||||
link.Arguments = Arguments;
|
||||
link.WorkingDirectory = Path.GetDirectoryName(_launcherPath);
|
||||
link.IconLocation = _launcherPath + ",0";
|
||||
link.IconLocation = icon;
|
||||
link.Description = _config.GameName;
|
||||
link.Save();
|
||||
return true;
|
||||
}));
|
||||
Log.Info($"Raccourci créé : {ShortcutPath}");
|
||||
NotifyShellUpdated(ShortcutPath);
|
||||
Log.Info($"Raccourci créé : {ShortcutPath} (icône : {icon})");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("Création du raccourci impossible", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,12 +106,18 @@ public sealed class ShortcutService : IShortcutService
|
||||
{
|
||||
foreach (var shortcut in FindDesktopShortcuts())
|
||||
{
|
||||
// Raccourci valide, ou vers un autre exemplaire du launcher encore présent : on n'y touche
|
||||
// pas ici (le bouton « Réparer » propose de le rediriger, après confirmation).
|
||||
if (shortcut.PointsToThisLauncher || shortcut.TargetExists) continue;
|
||||
// Raccourci vers un launcher disparu (déplacé ou renommé) : redirigé. Vers un autre
|
||||
// exemplaire du launcher encore présent : on n'y touche pas ici (le bouton « Réparer »
|
||||
// propose de le rediriger, après confirmation).
|
||||
if (!shortcut.PointsToThisLauncher && !shortcut.TargetExists)
|
||||
{
|
||||
Log.Info($"Raccourci « {shortcut.Name} » vers un launcher disparu ({shortcut.TargetPath}) : mise à jour vers {_launcherPath}");
|
||||
RetargetDesktopShortcut(shortcut);
|
||||
continue;
|
||||
}
|
||||
|
||||
Log.Info($"Raccourci « {shortcut.Name} » vers un launcher disparu ({shortcut.TargetPath}) : mise à jour vers {_launcherPath}");
|
||||
RetargetDesktopShortcut(shortcut);
|
||||
// Icône posée par le launcher : celle du jeu, à son emplacement actuel.
|
||||
RefreshIcon(shortcut);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,9 +149,11 @@ public sealed class ShortcutService : IShortcutService
|
||||
{
|
||||
string? target;
|
||||
string? arguments;
|
||||
string? icon;
|
||||
try
|
||||
{
|
||||
(target, arguments) = WithLink(shell, file, link => ((string?)link.TargetPath, (string?)link.Arguments));
|
||||
(target, arguments, icon) = WithLink(shell, file,
|
||||
link => ((string?)link.TargetPath, (string?)link.Arguments, (string?)link.IconLocation));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -112,7 +162,10 @@ public sealed class ShortcutService : IShortcutService
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(target) || !IsForThisGame(arguments)) continue;
|
||||
found.Add(new DesktopShortcut(file, target, SamePath(target, _launcherPath), File.Exists(target)));
|
||||
found.Add(new DesktopShortcut(file, target, SamePath(target, _launcherPath), File.Exists(target))
|
||||
{
|
||||
IconLocation = icon
|
||||
});
|
||||
}
|
||||
return true;
|
||||
});
|
||||
@@ -128,20 +181,21 @@ public sealed class ShortcutService : IShortcutService
|
||||
{
|
||||
try
|
||||
{
|
||||
var desiredIcon = DesiredIconLocation;
|
||||
WithShell(shell => WithLink(shell, shortcut.FilePath, link =>
|
||||
{
|
||||
// Icône personnalisée par le joueur : conservée. Icône de l'ancien launcher : remplacée.
|
||||
var iconFile = IconFile((string?)link.IconLocation);
|
||||
var keepIcon = iconFile is not null && File.Exists(iconFile) && !SamePath(iconFile, shortcut.TargetPath);
|
||||
// Icône personnalisée par le joueur : conservée. Icône posée par le launcher : celle du jeu.
|
||||
var keepIcon = !IsLauncherManagedIcon((string?)link.IconLocation, shortcut.TargetPath);
|
||||
|
||||
link.TargetPath = _launcherPath;
|
||||
link.Arguments = Arguments;
|
||||
link.WorkingDirectory = Path.GetDirectoryName(_launcherPath);
|
||||
if (!keepIcon) link.IconLocation = _launcherPath + ",0";
|
||||
if (!keepIcon) link.IconLocation = desiredIcon;
|
||||
link.Save();
|
||||
return true;
|
||||
}));
|
||||
Log.Info($"Raccourci « {shortcut.Name} » redirigé : {shortcut.TargetPath} → {_launcherPath}");
|
||||
NotifyShellUpdated(shortcut.FilePath);
|
||||
Log.Info($"Raccourci « {shortcut.Name} » mis à jour : {shortcut.TargetPath} → {_launcherPath}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -153,6 +207,76 @@ public sealed class ShortcutService : IShortcutService
|
||||
|
||||
public void RemoveDesktopShortcut() => FileUtil.TryDeleteFile(ShortcutPath);
|
||||
|
||||
/// <summary>Icône posée par le launcher remplacée par celle du jeu (à son emplacement actuel).</summary>
|
||||
private void RefreshIcon(DesktopShortcut shortcut)
|
||||
{
|
||||
var desired = DesiredIconLocation;
|
||||
if (string.Equals(shortcut.IconLocation, desired, StringComparison.OrdinalIgnoreCase) ||
|
||||
!IsLauncherManagedIcon(shortcut.IconLocation, shortcut.TargetPath))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
WithShell(shell => WithLink(shell, shortcut.FilePath, link =>
|
||||
{
|
||||
link.IconLocation = desired;
|
||||
link.Save();
|
||||
return true;
|
||||
}));
|
||||
NotifyShellUpdated(shortcut.FilePath);
|
||||
Log.Info($"Icône du raccourci « {shortcut.Name} » : {desired}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warn($"Mise à jour de l'icône du raccourci impossible ({shortcut.FilePath}) : {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vrai si l'icône a été posée par le launcher (et peut donc être remplacée) : absente, disparue,
|
||||
/// celle d'un launcher (actuel ou ancien emplacement) ou celle d'un exécutable du jeu. Faux pour
|
||||
/// une icône choisie par le joueur.
|
||||
/// </summary>
|
||||
private bool IsLauncherManagedIcon(string? iconLocation, string? previousTarget)
|
||||
{
|
||||
var iconFile = IconFile(iconLocation);
|
||||
if (iconFile is null || !File.Exists(iconFile)) return true;
|
||||
if (SamePath(iconFile, _launcherPath) || SamePath(iconFile, previousTarget)) return true;
|
||||
|
||||
var name = Path.GetFileName(iconFile);
|
||||
var gameExe = GameExecutablePath;
|
||||
return (gameExe is not null && SamePath(iconFile, gameExe)) ||
|
||||
string.Equals(name, Path.GetFileName(_config.GameExecutable), StringComparison.OrdinalIgnoreCase) ||
|
||||
(gameExe is not null && string.Equals(name, Path.GetFileName(gameExe), StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
/// <summary>Demande à l'Explorateur de redessiner le raccourci (nouvelle icône visible tout de suite).</summary>
|
||||
private static void NotifyShellUpdated(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pointer = Marshal.StringToHGlobalUni(path);
|
||||
try
|
||||
{
|
||||
SHChangeNotify(SHCNE_UPDATEITEM, SHCNF_PATHW, pointer, IntPtr.Zero);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeHGlobal(pointer);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warn($"Actualisation de l'icône du raccourci : {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private const int SHCNE_UPDATEITEM = 0x00002000;
|
||||
private const uint SHCNF_PATHW = 0x0005;
|
||||
|
||||
[DllImport("shell32.dll")]
|
||||
private static extern void SHChangeNotify(int eventId, uint flags, IntPtr item1, IntPtr item2);
|
||||
|
||||
// ------------------------------------------------------------------ Utilitaires
|
||||
|
||||
/// <summary>Arguments du raccourci contenant « --game <GameId> » (guillemets tolérés).</summary>
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
<Geometry x:Key="Icon.Cog">M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.21,8.95 2.27,9.22 2.46,9.37L4.57,11C4.53,11.34 4.5,11.67 4.5,12C4.5,12.33 4.53,12.65 4.57,12.97L2.46,14.63C2.27,14.78 2.21,15.05 2.34,15.27L4.34,18.73C4.46,18.95 4.73,19.03 4.95,18.95L7.44,17.94C7.96,18.34 8.5,18.68 9.13,18.93L9.5,21.58C9.54,21.82 9.75,22 10,22H14C14.25,22 14.46,21.82 14.5,21.58L14.87,18.93C15.5,18.67 16.04,18.34 16.56,17.94L19.05,18.95C19.27,19.03 19.54,18.95 19.66,18.73L21.66,15.27C21.78,15.05 21.73,14.78 21.54,14.63L19.43,12.97Z</Geometry>
|
||||
<Geometry x:Key="Icon.Close">M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z</Geometry>
|
||||
<Geometry x:Key="Icon.Minimize">M20,14H4V10H20</Geometry>
|
||||
<Geometry x:Key="Icon.WindowRestore">M4,8H8V4H20V16H16V20H4V8M16,8V14H18V6H10V8H16M6,12V18H14V12H6Z</Geometry>
|
||||
<Geometry x:Key="Icon.ExitToApp">M19,3H5C3.89,3 3,3.89 3,5V9H5V5H19V19H5V15H3V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3M10.08,15.58L11.5,17L16.5,12L11.5,7L10.08,8.41L12.67,11H3V13H12.67L10.08,15.58Z</Geometry>
|
||||
<Geometry x:Key="Icon.Folder">M10,4H4C2.89,4 2,4.89 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V8C22,6.89 21.1,6 20,6H12L10,4Z</Geometry>
|
||||
<Geometry x:Key="Icon.FolderOpen">M19,20H4C2.89,20 2,19.1 2,18V6C2,4.89 2.89,4 4,4H10L12,6H19A2,2 0 0,1 21,8H21L4,8V18L6.14,10H23.21L20.93,18.5C20.7,19.37 19.92,20 19,20Z</Geometry>
|
||||
<Geometry x:Key="Icon.Locate">M12,8A4,4 0 0,1 16,12A4,4 0 0,1 12,16A4,4 0 0,1 8,12A4,4 0 0,1 12,8M3.05,13H1V11H3.05C3.5,6.83 6.83,3.5 11,3.05V1H13V3.05C17.17,3.5 20.5,6.83 20.95,11H23V13H20.95C20.5,17.17 17.17,20.5 13,20.95V23H11V20.95C6.83,20.5 3.5,17.17 3.05,13M12,5A7,7 0 0,0 5,12A7,7 0 0,0 12,19A7,7 0 0,0 19,12A7,7 0 0,0 12,5Z</Geometry>
|
||||
|
||||
@@ -7,16 +7,30 @@ using GameLauncher.Services;
|
||||
namespace GameLauncher.ViewModels;
|
||||
|
||||
/// <summary>Informations du jeu affichées dans ses paramètres (instantané pris à l'ouverture).</summary>
|
||||
/// <param name="LogsDirectory">Journaux de Unity (Player.log) : AppData\LocalLow\Société\Produit.</param>
|
||||
/// <param name="DataDirectory">Sauvegardes et réglages du jeu : AppData\Roaming\Société\Produit.</param>
|
||||
public sealed record GameSettingsInfo(
|
||||
string GameName,
|
||||
string? InstallPath,
|
||||
string InstalledVersion,
|
||||
string LatestVersion,
|
||||
string InstallSize);
|
||||
string InstallSize,
|
||||
string? LogsDirectory = null,
|
||||
string? DataDirectory = null);
|
||||
|
||||
/// <summary>Résultat du bouton « Raccourci sur le Bureau ».</summary>
|
||||
public sealed record ShortcutActionResult(bool Success, string Message);
|
||||
|
||||
/// <summary>
|
||||
/// Raccourci du Bureau depuis les paramètres du jeu : nom du raccourci existant (ou null), et
|
||||
/// création / mise à jour (action immédiate, indépendante de « Enregistrer »).
|
||||
/// </summary>
|
||||
public sealed record GameShortcutActions(Func<string?> FindExisting, Func<ShortcutActionResult> CreateOrUpdate);
|
||||
|
||||
/// <summary>
|
||||
/// Paramètres du jeu sélectionné (menu « … » › Paramètres du jeu) : mises à jour automatiques
|
||||
/// du jeu, téléchargement et informations sur l'installation. Enregistrés pour ce jeu seulement.
|
||||
/// du jeu, téléchargement, raccourci du Bureau et informations sur l'installation. Enregistrés
|
||||
/// pour ce jeu seulement.
|
||||
/// </summary>
|
||||
public sealed class GameSettingsViewModel : SettingsViewModelBase
|
||||
{
|
||||
@@ -24,10 +38,17 @@ public sealed class GameSettingsViewModel : SettingsViewModelBase
|
||||
private bool _autoUpdate;
|
||||
private bool _verifyAfterInstall;
|
||||
private int _maxParallelDownloads;
|
||||
private readonly GameShortcutActions? _shortcut;
|
||||
private string _shortcutStatus = "";
|
||||
private DialogKind _shortcutStatusKind = DialogKind.Info;
|
||||
private bool _shortcutExists;
|
||||
|
||||
public GameSettingsViewModel(LauncherSettings settings, GameSettingsInfo info, IWindowService window)
|
||||
/// <param name="shortcut">Absent : la section « Raccourci » n'est pas affichée (captures d'écran).</param>
|
||||
public GameSettingsViewModel(LauncherSettings settings, GameSettingsInfo info, IWindowService window,
|
||||
GameShortcutActions? shortcut = null)
|
||||
{
|
||||
_window = window;
|
||||
_shortcut = shortcut;
|
||||
_autoUpdate = settings.AutoUpdate;
|
||||
_verifyAfterInstall = settings.VerifyAfterInstall;
|
||||
_maxParallelDownloads = Math.Clamp(settings.MaxParallelDownloads, 1, 16);
|
||||
@@ -45,11 +66,18 @@ public sealed class GameSettingsViewModel : SettingsViewModelBase
|
||||
OpenInstallFolderCommand = new RelayCommand(
|
||||
() => { if (CanOpenInstallFolder) _window.OpenFolder(info.InstallPath!); },
|
||||
() => CanOpenInstallFolder);
|
||||
OpenLogsCommand = new RelayCommand(() =>
|
||||
{
|
||||
var dir = Path.GetDirectoryName(Log.FilePath ?? "");
|
||||
if (!string.IsNullOrEmpty(dir)) _window.OpenFolder(dir);
|
||||
});
|
||||
// Journaux du jeu (Player.log de Unity, dans LocalLow) et données du jeu (sauvegardes et
|
||||
// réglages, dans Roaming). Les journaux du launcher sont dans les paramètres du launcher.
|
||||
LogsDirectory = info.LogsDirectory;
|
||||
DataDirectory = info.DataDirectory;
|
||||
OpenLogsCommand = new RelayCommand(
|
||||
() => { if (Directory.Exists(LogsDirectory)) _window.OpenFolder(LogsDirectory!); },
|
||||
() => Directory.Exists(LogsDirectory));
|
||||
OpenDataCommand = new RelayCommand(
|
||||
() => { if (Directory.Exists(DataDirectory)) _window.OpenFolder(DataDirectory!); },
|
||||
() => Directory.Exists(DataDirectory));
|
||||
CreateShortcutCommand = new RelayCommand(CreateShortcut, () => IsInstalled && _shortcut is not null);
|
||||
RefreshShortcutStatus();
|
||||
}
|
||||
|
||||
public override string Title => "Paramètres du jeu";
|
||||
@@ -78,6 +106,58 @@ public sealed class GameSettingsViewModel : SettingsViewModelBase
|
||||
public RelayCommand IncreaseParallelCommand { get; }
|
||||
public RelayCommand DecreaseParallelCommand { get; }
|
||||
|
||||
// ------------------------------------------------------------------ Raccourci du Bureau
|
||||
|
||||
public bool HasShortcutActions => _shortcut is not null;
|
||||
|
||||
public string ShortcutStatus { get => _shortcutStatus; private set => SetProperty(ref _shortcutStatus, value); }
|
||||
|
||||
/// <summary>Couleur/icône du statut (Info, Success, Error).</summary>
|
||||
public DialogKind ShortcutStatusKind { get => _shortcutStatusKind; private set => SetProperty(ref _shortcutStatusKind, value); }
|
||||
|
||||
public string ShortcutButtonText => _shortcutExists ? "Mettre à jour" : "Créer le raccourci";
|
||||
|
||||
public RelayCommand CreateShortcutCommand { get; }
|
||||
|
||||
private void RefreshShortcutStatus()
|
||||
{
|
||||
if (_shortcut is null) return;
|
||||
if (!IsInstalled)
|
||||
{
|
||||
ShortcutStatus = "Installez le jeu pour créer son raccourci.";
|
||||
return;
|
||||
}
|
||||
|
||||
string? existing = null;
|
||||
try { existing = _shortcut.FindExisting(); }
|
||||
catch (Exception ex) { Log.Warn($"Recherche du raccourci du Bureau : {ex.Message}"); }
|
||||
|
||||
_shortcutExists = existing is not null;
|
||||
OnPropertyChanged(nameof(ShortcutButtonText));
|
||||
ShortcutStatus = _shortcutExists
|
||||
? $"Raccourci présent sur le Bureau (« {existing} »). « Mettre à jour » lui redonne l'icône du jeu et l'emplacement actuel du launcher."
|
||||
: "Ouvre le launcher sur ce jeu, vérifie les mises à jour puis lance la partie. Avec l'icône du jeu.";
|
||||
}
|
||||
|
||||
private void CreateShortcut()
|
||||
{
|
||||
if (_shortcut is null) return;
|
||||
ShortcutActionResult result;
|
||||
try
|
||||
{
|
||||
result = _shortcut.CreateOrUpdate();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("Raccourci du Bureau", ex);
|
||||
result = new ShortcutActionResult(false, "Le raccourci n'a pas pu être créé. Consultez le dossier des journaux.");
|
||||
}
|
||||
|
||||
RefreshShortcutStatus();
|
||||
ShortcutStatus = result.Message;
|
||||
ShortcutStatusKind = result.Success ? DialogKind.Success : DialogKind.Error;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Informations
|
||||
|
||||
public string GameName { get; }
|
||||
@@ -90,8 +170,27 @@ public sealed class GameSettingsViewModel : SettingsViewModelBase
|
||||
private bool CanOpenInstallFolder => IsInstalled && Directory.Exists(InstallPath);
|
||||
|
||||
public RelayCommand OpenInstallFolderCommand { get; }
|
||||
|
||||
/// <summary>Dossier des journaux du jeu (Player.log de Unity), dans LocalLow.</summary>
|
||||
public string? LogsDirectory { get; }
|
||||
|
||||
/// <summary>Dossier des données du jeu (sauvegardes, réglages), dans Roaming.</summary>
|
||||
public string? DataDirectory { get; }
|
||||
|
||||
public string LogsToolTip => Directory.Exists(LogsDirectory)
|
||||
? $"Journaux du jeu (Player.log) :\n{LogsDirectory}"
|
||||
: $"Créé au premier lancement du jeu :\n{LogsDirectory}";
|
||||
|
||||
public string DataToolTip => Directory.Exists(DataDirectory)
|
||||
? $"Sauvegardes et réglages du jeu :\n{DataDirectory}"
|
||||
: $"Créé au premier lancement du jeu :\n{DataDirectory}";
|
||||
|
||||
/// <summary>Ouvre le dossier des journaux du jeu (LocalLow).</summary>
|
||||
public RelayCommand OpenLogsCommand { get; }
|
||||
|
||||
/// <summary>Ouvre le dossier des données du jeu (Roaming).</summary>
|
||||
public RelayCommand OpenDataCommand { get; }
|
||||
|
||||
/// <summary>Paramètres à enregistrer pour ce jeu (les anciens réglages de lancement n'y sont plus écrits).</summary>
|
||||
public LauncherSettings ToSettings() => new()
|
||||
{
|
||||
|
||||
@@ -47,6 +47,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
private readonly IDialogService _dialogs;
|
||||
private readonly IWindowService _window;
|
||||
private readonly ILauncherUpdateService? _launcherUpdates;
|
||||
private readonly string? _launcherDataDirectory;
|
||||
private readonly SynchronizationContext _ui;
|
||||
private readonly CancellationTokenSource _lifetime = new();
|
||||
private readonly List<RelayCommand> _commands = new();
|
||||
@@ -67,8 +68,9 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
LauncherPreferences preferences, GameInstaller installer, ManifestClient manifestClient,
|
||||
IRegistryService registry, IShortcutService shortcuts,
|
||||
IDialogService dialogs, IWindowService window, string launcherVersion,
|
||||
ILauncherUpdateService? launcherUpdates = null)
|
||||
ILauncherUpdateService? launcherUpdates = null, string? launcherDataDirectory = null)
|
||||
{
|
||||
_launcherDataDirectory = launcherDataDirectory;
|
||||
_config = config;
|
||||
_settings = settings;
|
||||
_settingsPath = settingsPath;
|
||||
@@ -498,6 +500,8 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
{
|
||||
_registry.Write(new GameRegistration(Path.GetFullPath(directory), state.Version, state.Executable, state.InstalledAt),
|
||||
state.TotalSize);
|
||||
// Le jeu a pu changer d'emplacement (localisation, réinstallation) : l'icône des raccourcis le suit.
|
||||
_shortcuts.RefreshDesktopShortcut();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Mises à jour
|
||||
@@ -1016,33 +1020,59 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
return false;
|
||||
}
|
||||
|
||||
const string deleteEverythingOption = "Supprimer aussi les sauvegardes, les réglages, les journaux et tous les autres fichiers du jeu";
|
||||
var keptData = $"Par défaut, vos sauvegardes et vos réglages sont conservés :\n{_config.GameDataDirectory}";
|
||||
|
||||
// Launcher placé dans un dossier concerné par la désinstallation : ses fichiers sont toujours
|
||||
// conservés (dossier du jeu, ou dossiers des sauvegardes et des journaux avec l'option).
|
||||
var launcherDirectory = Path.GetDirectoryName(_shortcuts.LauncherPath);
|
||||
var launcherFiles = LauncherSelfFiles.Get(_shortcuts.LauncherPath);
|
||||
var launcherInData = launcherDirectory is not null &&
|
||||
new[] { _config.GameDataDirectory, _config.GameLogsDirectory }
|
||||
.Any(d => LauncherSelfFiles.IsInside(launcherDirectory, d));
|
||||
var launcherInDataNotice = launcherInData
|
||||
? "\n\nLe launcher se trouve dans le dossier des données du jeu : il sera conservé."
|
||||
: "";
|
||||
|
||||
if (State == LauncherState.Broken)
|
||||
{
|
||||
if (!_dialogs.Confirm($"Désinstaller {GameName}",
|
||||
"Les fichiers du jeu ont déjà été supprimés. L'enregistrement du jeu dans Windows va être retiré.",
|
||||
"Désinstaller", "Annuler", DialogKind.Danger))
|
||||
return false;
|
||||
var (confirmedBroken, deleteDataBroken) = _dialogs.ConfirmWithOption(
|
||||
$"Désinstaller {GameName}",
|
||||
$"Les fichiers du jeu ont déjà été supprimés. L'enregistrement du jeu dans Windows va être retiré.\n\n{keptData}" +
|
||||
launcherInDataNotice,
|
||||
deleteEverythingOption, "Désinstaller", "Annuler", DialogKind.Danger);
|
||||
if (!confirmedBroken) return false;
|
||||
|
||||
var dataBroken = deleteDataBroken ? DeleteGameUserData(launcherFiles) : null;
|
||||
_registry.Delete();
|
||||
_shortcuts.RemoveDesktopShortcut();
|
||||
HideBanner();
|
||||
RefreshInstallState();
|
||||
ShowBanner("Désinstallation terminée", $"{GameName} a été désinstallé.", DialogKind.Success);
|
||||
ShowUninstallBanner(null, null, dataBroken, dataBroken?.LauncherKeptIn);
|
||||
return true;
|
||||
}
|
||||
|
||||
var dir = InstallPath!;
|
||||
var launcherInGame = launcherDirectory is not null && LauncherSelfFiles.IsInside(launcherDirectory, dir);
|
||||
|
||||
var (confirmed, deleteEverything) = _dialogs.ConfirmWithOption(
|
||||
$"Désinstaller {GameName}",
|
||||
$"Le jeu va être supprimé de :\n{dir}\n\nPar défaut, vos sauvegardes et fichiers personnels présents dans ce dossier sont conservés.",
|
||||
"Supprimer aussi les sauvegardes et tous les autres fichiers du dossier",
|
||||
"Désinstaller", "Annuler", DialogKind.Danger);
|
||||
$"Le jeu va être supprimé de :\n{dir}\n\n{keptData}\n\nLes fichiers personnels présents dans le dossier du jeu sont aussi conservés." +
|
||||
(launcherInGame ? "\n\nLe launcher se trouve dans ce dossier : il sera conservé." : "") +
|
||||
launcherInDataNotice,
|
||||
deleteEverythingOption, "Désinstaller", "Annuler", DialogKind.Danger);
|
||||
if (!confirmed) return false;
|
||||
|
||||
UninstallResult? result = null;
|
||||
UserDataDeletion? data = null;
|
||||
var manifest = _manifest;
|
||||
var ok = await RunOperationAsync("Désinstallation", async (progress, ct) =>
|
||||
{
|
||||
result = await _installer.UninstallAsync(dir, manifest, deleteEverything, progress, ct);
|
||||
// Fichiers bloqués : signalés dans le résultat, la désinstallation continue (registre,
|
||||
// raccourci, données du jeu).
|
||||
result = await _installer.UninstallAsync(dir, manifest, deleteEverything, progress, ct, launcherFiles);
|
||||
// Données du jeu hors du dossier d'installation (sauvegardes, réglages, journaux).
|
||||
if (deleteEverything) data = DeleteGameUserData(launcherFiles);
|
||||
_registry.Delete();
|
||||
_shortcuts.RemoveDesktopShortcut();
|
||||
_needsRepair = false;
|
||||
@@ -1050,15 +1080,118 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
|
||||
if (ok && result is not null)
|
||||
{
|
||||
var message = result.RemainingFiles > 0
|
||||
? $"{GameName} a été désinstallé. " +
|
||||
$"{Format.Plural(result.RemainingFiles, "fichier personnel a été conservé", "fichiers personnels ont été conservés")} dans « {dir} »."
|
||||
: $"{GameName} a été désinstallé.";
|
||||
ShowBanner("Désinstallation terminée", message, DialogKind.Success);
|
||||
var launcherKeptIn = result.LauncherFilesKept > 0 || launcherInGame ? launcherDirectory : data?.LauncherKeptIn;
|
||||
ShowUninstallBanner(result, dir, data, launcherKeptIn);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
/// <summary>Résultat de la suppression des données du jeu (option « Supprimer aussi… »).</summary>
|
||||
/// <param name="RefusedDirectories">Dossiers non supprimés par sécurité.</param>
|
||||
/// <param name="LockedFiles">Fichiers bloqués (fichier ouvert, antivirus…).</param>
|
||||
/// <param name="LauncherKeptIn">Dossier où les fichiers du launcher ont été conservés, sinon null.</param>
|
||||
private sealed record UserDataDeletion(List<string> RefusedDirectories, List<string> LockedFiles, string? LauncherKeptIn);
|
||||
|
||||
/// <summary>Bandeau de fin de désinstallation : ce qui a été supprimé, conservé ou bloqué.</summary>
|
||||
/// <param name="data">Null : option « Supprimer aussi… » non cochée.</param>
|
||||
private void ShowUninstallBanner(UninstallResult? result, string? dir, UserDataDeletion? data, string? launcherKeptIn)
|
||||
{
|
||||
var message = $"{GameName} a été désinstallé.";
|
||||
var locked = (result?.LockedFiles ?? Array.Empty<string>()).Concat(data?.LockedFiles ?? new List<string>()).ToList();
|
||||
var refused = data?.RefusedDirectories ?? new List<string>();
|
||||
|
||||
if (result is { RemainingFiles: > 0 })
|
||||
message += $" {Format.Plural(result.RemainingFiles, "fichier personnel a été conservé", "fichiers personnels ont été conservés")} dans « {dir} ».";
|
||||
if (data is not null)
|
||||
message += refused.Count == 0
|
||||
? " Sauvegardes, réglages et journaux du jeu supprimés."
|
||||
: $" Sauvegardes, réglages et journaux du jeu supprimés, sauf (par sécurité) : {string.Join(", ", refused)}.";
|
||||
if (locked.Count > 0)
|
||||
message += $" {Format.Plural(locked.Count, "fichier n'a pas pu être supprimé", "fichiers n'ont pas pu être supprimés")} " +
|
||||
$"(fichier ouvert ou bloqué par l'antivirus ?) : {Path.GetFileName(locked[0])}{(locked.Count > 1 ? "…" : "")} " +
|
||||
"La liste complète est dans le journal du launcher.";
|
||||
if (launcherKeptIn is not null)
|
||||
message += $" Le launcher a été conservé dans « {launcherKeptIn} » : vous pouvez le déplacer ou le supprimer après l'avoir fermé.";
|
||||
|
||||
foreach (var file in locked) Log.Warn($"Non supprimé (bloqué) : {file}");
|
||||
|
||||
var warning = locked.Count > 0 || refused.Count > 0;
|
||||
ShowBanner("Désinstallation terminée", message, warning ? DialogKind.Warning : DialogKind.Success,
|
||||
autoHide: !warning && launcherKeptIn is null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Option « Supprimer aussi… » de la désinstallation : données du jeu hors de son dossier
|
||||
/// d'installation — sauvegardes et réglages (%APPDATA%\Société\Produit), journaux de Unity
|
||||
/// (AppData\LocalLow\Société\Produit) et réglages d'affichage de Unity (registre).
|
||||
/// Mêmes règles que pour le dossier du jeu : suppression fichier par fichier, fichiers du launcher
|
||||
/// (<paramref name="launcherFiles"/>) toujours conservés, fichiers bloqués signalés sans interrompre.
|
||||
/// </summary>
|
||||
private UserDataDeletion DeleteGameUserData(IReadOnlyCollection<string> launcherFiles)
|
||||
{
|
||||
var refused = new List<string>();
|
||||
var locked = new List<string>();
|
||||
string? launcherKeptIn = null;
|
||||
|
||||
foreach (var directory in new[] { _config.GameDataDirectory, _config.GameLogsDirectory })
|
||||
{
|
||||
if (!Directory.Exists(directory)) continue;
|
||||
if (!IsSafeUserDataDirectory(directory))
|
||||
{
|
||||
Log.Warn($"Dossier non supprimé par sécurité : {directory}");
|
||||
refused.Add(directory);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var deletion = FileUtil.DeleteDirectoryContents(directory, launcherFiles);
|
||||
locked.AddRange(deletion.LockedFiles);
|
||||
if (deletion.KeptFiles > 0) launcherKeptIn = Path.GetDirectoryName(_shortcuts.LauncherPath);
|
||||
Log.Info($"Données du jeu : {directory} — {deletion.DeletedFiles} fichier(s) supprimé(s), " +
|
||||
$"{deletion.LockedFiles.Count} bloqué(s), {deletion.KeptFiles} fichier(s) du launcher conservé(s).");
|
||||
|
||||
// Dossier de la société devenu vide (ex. LocalLow\LanisysGames) : supprimé aussi.
|
||||
if (deletion.FolderRemoved)
|
||||
{
|
||||
var parent = Path.GetDirectoryName(directory);
|
||||
if (parent is not null) FileUtil.TryRemoveEmptyDirectory(parent);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
Log.Warn($"Suppression de {directory} impossible : {ex.Message}");
|
||||
locked.Add(directory);
|
||||
}
|
||||
}
|
||||
|
||||
_registry.DeleteGameUserSettings();
|
||||
return new UserDataDeletion(refused, locked, launcherKeptIn);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Garde-fous avant une suppression récursive : chemin « Société\Produit » complet (jamais le
|
||||
/// dossier de la société entier), sans rapport avec le dossier du launcher ni avec celui du jeu.
|
||||
/// </summary>
|
||||
private bool IsSafeUserDataDirectory(string directory)
|
||||
{
|
||||
if (_config.GameDataRelativePath.Split('\\', StringSplitOptions.RemoveEmptyEntries).Length < 2) return false;
|
||||
|
||||
var full = Normalize(directory);
|
||||
foreach (var protectedPath in new[] { _launcherDataDirectory, InstallPath })
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(protectedPath)) continue;
|
||||
var other = Normalize(protectedPath);
|
||||
if (string.Equals(full, other, StringComparison.OrdinalIgnoreCase) ||
|
||||
other.StartsWith(full + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) ||
|
||||
full.StartsWith(other + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
static string Normalize(string path) => Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Lancement du jeu
|
||||
|
||||
/// <summary>
|
||||
@@ -1149,7 +1282,11 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
StatusDetail = "Le launcher se réactivera à la fermeture du jeu.";
|
||||
}
|
||||
|
||||
if (_preferences.MinimizeOnLaunch) _window.Minimize();
|
||||
if (_preferences.MinimizeOnLaunch)
|
||||
{
|
||||
if (_preferences.MinimizeToTray) _window.HideToTray(); // icône près de l'horloge
|
||||
else _window.Minimize(); // barre des tâches
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -1231,8 +1368,12 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
/// <summary>Paramètres propres à ce jeu (menu « … »). Les réglages communs sont dans les paramètres du launcher.</summary>
|
||||
private void OpenSettings()
|
||||
{
|
||||
var info = new GameSettingsInfo(GameName, InstallPath, InstalledVersionText, LatestVersionText, InstallSizeText);
|
||||
var vm = new GameSettingsViewModel(_settings, info, _window);
|
||||
var info = new GameSettingsInfo(GameName, InstallPath, InstalledVersionText, LatestVersionText, InstallSizeText,
|
||||
_config.GameLogsDirectory, _config.GameDataDirectory);
|
||||
var shortcut = new GameShortcutActions(
|
||||
() => _shortcuts.FindDesktopShortcuts().FirstOrDefault()?.Name,
|
||||
CreateOrUpdateDesktopShortcut);
|
||||
var vm = new GameSettingsViewModel(_settings, info, _window, shortcut);
|
||||
if (!_dialogs.ShowSettings(vm)) return;
|
||||
|
||||
_settings = vm.ToSettings();
|
||||
@@ -1243,6 +1384,28 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
if (_settings.AutoUpdate && State == LauncherState.UpdateAvailable) Fire(UpdateAsync);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bouton « Raccourci sur le Bureau » des paramètres du jeu : crée le raccourci, ou met à jour
|
||||
/// ceux qui existent déjà (emplacement actuel du launcher, icône du jeu).
|
||||
/// </summary>
|
||||
private ShortcutActionResult CreateOrUpdateDesktopShortcut()
|
||||
{
|
||||
var existing = _shortcuts.FindDesktopShortcuts();
|
||||
if (existing.Count > 0)
|
||||
{
|
||||
var updated = existing.Count(_shortcuts.RetargetDesktopShortcut);
|
||||
return updated == existing.Count
|
||||
? new ShortcutActionResult(true, existing.Count == 1
|
||||
? $"Le raccourci « {existing[0].Name} » existait déjà : il a été mis à jour."
|
||||
: $"{existing.Count} raccourcis existaient déjà : ils ont été mis à jour.")
|
||||
: new ShortcutActionResult(false, "La mise à jour du raccourci a échoué. Consultez le dossier des journaux.");
|
||||
}
|
||||
|
||||
return _shortcuts.CreateDesktopShortcut()
|
||||
? new ShortcutActionResult(true, "Raccourci créé sur le Bureau.")
|
||||
: new ShortcutActionResult(false, "La création du raccourci a échoué. Consultez le dossier des journaux.");
|
||||
}
|
||||
|
||||
private void ApplySettings()
|
||||
{
|
||||
_installer.MaxParallelDownloads = _settings.MaxParallelDownloads;
|
||||
|
||||
@@ -22,6 +22,8 @@ public sealed class LauncherSettingsViewModel : SettingsViewModelBase
|
||||
private LauncherUpdateCheck? _availableLauncherUpdate;
|
||||
private bool _minimizeOnLaunch;
|
||||
private bool _closeOnLaunch;
|
||||
private bool _minimizeToTray;
|
||||
private bool _closeToTray;
|
||||
|
||||
public LauncherSettingsViewModel(LauncherPreferences preferences, string launcherName, string launcherVersion,
|
||||
IWindowService window, ILauncherUpdateService? launcherUpdates = null)
|
||||
@@ -38,6 +40,8 @@ public sealed class LauncherSettingsViewModel : SettingsViewModelBase
|
||||
_launcherStatusKind = launcherUpdates is { IsEnabled: true } ? DialogKind.Info : DialogKind.Warning;
|
||||
_minimizeOnLaunch = preferences.MinimizeOnLaunch;
|
||||
_closeOnLaunch = preferences.CloseOnLaunch;
|
||||
_minimizeToTray = preferences.MinimizeToTray;
|
||||
_closeToTray = preferences.CloseToTray;
|
||||
LauncherVersion = launcherVersion;
|
||||
Subtitle = string.IsNullOrWhiteSpace(launcherName)
|
||||
? "Réglages communs à tous les jeux."
|
||||
@@ -167,10 +171,20 @@ public sealed class LauncherSettingsViewModel : SettingsViewModelBase
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Pendant la partie : réduit dans la zone de notification plutôt que dans la barre des tâches.</summary>
|
||||
public bool MinimizeToTray { get => _minimizeToTray; set => SetProperty(ref _minimizeToTray, value); }
|
||||
|
||||
// ------------------------------------------------------------------ Fermeture de la fenêtre
|
||||
|
||||
/// <summary>La croix réduit le launcher dans la zone de notification au lieu de le quitter.</summary>
|
||||
public bool CloseToTray { get => _closeToTray; set => SetProperty(ref _closeToTray, value); }
|
||||
|
||||
/// <summary>Recopie les choix dans l'objet partagé par tous les jeux.</summary>
|
||||
public void ApplyTo(LauncherPreferences preferences)
|
||||
{
|
||||
preferences.MinimizeOnLaunch = MinimizeOnLaunch;
|
||||
preferences.CloseOnLaunch = CloseOnLaunch;
|
||||
preferences.MinimizeToTray = MinimizeToTray;
|
||||
preferences.CloseToTray = CloseToTray;
|
||||
}
|
||||
}
|
||||
@@ -49,9 +49,8 @@ public sealed class LauncherViewModel : ObservableObject, IDisposable
|
||||
MinimizeCommand = new RelayCommand(_window.Minimize);
|
||||
CloseCommand = new RelayCommand(() =>
|
||||
{
|
||||
if (!CanClose()) return;
|
||||
IsExitConfirmed = true;
|
||||
_window.Shutdown();
|
||||
if (CloseToTray) HideToTray(); // option « La croix réduit le launcher dans la zone de notification »
|
||||
else Quit();
|
||||
});
|
||||
OpenLauncherSettingsCommand = new RelayCommand(OpenLauncherSettings);
|
||||
}
|
||||
@@ -69,6 +68,31 @@ public sealed class LauncherViewModel : ObservableObject, IDisposable
|
||||
public RelayCommand MinimizeCommand { get; }
|
||||
public RelayCommand CloseCommand { get; }
|
||||
|
||||
/// <summary>La croix réduit le launcher dans la zone de notification au lieu de le quitter.</summary>
|
||||
public bool CloseToTray => _preferences.CloseToTray;
|
||||
|
||||
/// <summary>Info-bulle de la croix, selon l'option ci-dessus.</summary>
|
||||
public string CloseToolTip => CloseToTray ? "Réduire dans la zone de notification" : "Fermer";
|
||||
|
||||
/// <summary>Masque la fenêtre ; le launcher reste actif (icône près de l'horloge).</summary>
|
||||
public void HideToTray()
|
||||
{
|
||||
Log.Info("Launcher réduit dans la zone de notification.");
|
||||
_window.HideToTray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Quitte réellement le launcher (croix sans l'option, ou « Quitter » dans le menu de l'icône).
|
||||
/// Si une opération est en cours, la fenêtre est réaffichée pour la confirmation.
|
||||
/// </summary>
|
||||
public void Quit()
|
||||
{
|
||||
if (Games.Any(g => g.IsBusy)) _window.Restore();
|
||||
if (!CanClose()) return;
|
||||
IsExitConfirmed = true;
|
||||
_window.Shutdown();
|
||||
}
|
||||
|
||||
/// <summary>Roue crantée : paramètres du launcher, communs à tous les jeux.</summary>
|
||||
public RelayCommand OpenLauncherSettingsCommand { get; }
|
||||
|
||||
@@ -232,6 +256,8 @@ public sealed class LauncherViewModel : ObservableObject, IDisposable
|
||||
// Objet partagé par tous les GameViewModel : pris en compte au prochain lancement d'un jeu.
|
||||
vm.ApplyTo(_preferences);
|
||||
_preferences.Save(_preferencesPath);
|
||||
OnPropertyChanged(nameof(CloseToTray));
|
||||
OnPropertyChanged(nameof(CloseToolTip));
|
||||
|
||||
if (_launcherUpdates is { IsEnabled: true }) _launcherUpdates.AutoUpdate = vm.LauncherAutoUpdate;
|
||||
|
||||
|
||||
@@ -253,7 +253,7 @@
|
||||
<Button Style="{StaticResource IconButtonStyle}" ToolTip="Réduire" Padding="12"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Minimize}"
|
||||
Command="{Binding DataContext.MinimizeCommand, RelativeSource={RelativeSource AncestorType=Window}}" />
|
||||
<Button Style="{StaticResource CloseButtonStyle}" ToolTip="Fermer" Padding="12" Margin="4,0,0,0"
|
||||
<Button Style="{StaticResource CloseButtonStyle}" ToolTip="{Binding DataContext.CloseToolTip, RelativeSource={RelativeSource AncestorType=Window}}" Padding="12" Margin="4,0,0,0"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Close}"
|
||||
Command="{Binding DataContext.CloseCommand, RelativeSource={RelativeSource AncestorType=Window}}" />
|
||||
</StackPanel>
|
||||
|
||||
@@ -161,10 +161,25 @@ public partial class MainWindow : Window, IThemeTransition
|
||||
encoder.Save(stream);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fermeture par Alt+F4 ou la barre des tâches : même règle que la croix (zone de notification
|
||||
/// si l'option est activée, sinon confirmation si une opération est en cours).
|
||||
/// </summary>
|
||||
protected override void OnClosing(CancelEventArgs e)
|
||||
{
|
||||
if (DataContext is LauncherViewModel vm && !vm.IsExitConfirmed && !vm.CanClose())
|
||||
e.Cancel = true;
|
||||
if (DataContext is LauncherViewModel { IsExitConfirmed: false } vm)
|
||||
{
|
||||
if (vm.CloseToTray)
|
||||
{
|
||||
e.Cancel = true;
|
||||
// Une fenêtre ne peut pas être masquée pendant sa fermeture : juste après.
|
||||
Dispatcher.BeginInvoke(new Action(vm.HideToTray));
|
||||
}
|
||||
else if (!vm.CanClose())
|
||||
{
|
||||
e.Cancel = true;
|
||||
}
|
||||
}
|
||||
base.OnClosing(e);
|
||||
}
|
||||
}
|
||||
@@ -132,6 +132,15 @@
|
||||
Style="{StaticResource SettingDescriptionStyle}" />
|
||||
</StackPanel>
|
||||
</CheckBox>
|
||||
<!-- Sous-option de « Réduire » : barre des tâches ou zone de notification -->
|
||||
<CheckBox Style="{StaticResource ToggleSwitchStyle}" Margin="24,0,0,12" IsChecked="{Binding MinimizeToTray}"
|
||||
IsEnabled="{Binding MinimizeOnLaunch}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Dans la zone de notification" Style="{StaticResource SettingTitleStyle}" />
|
||||
<TextBlock Text="Pendant la partie, le launcher quitte la barre des tâches et attend près de l'horloge (icônes cachées)."
|
||||
Style="{StaticResource SettingDescriptionStyle}" />
|
||||
</StackPanel>
|
||||
</CheckBox>
|
||||
<Border Height="1" Background="{DynamicResource BorderBrush}" />
|
||||
<CheckBox Style="{StaticResource ToggleSwitchStyle}" Margin="0,12" IsChecked="{Binding CloseOnLaunch}">
|
||||
<StackPanel>
|
||||
@@ -142,6 +151,18 @@
|
||||
</CheckBox>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Bouton ✕ de la fenêtre : commun à tous les jeux -->
|
||||
<TextBlock Text="FERMETURE DU LAUNCHER" Style="{StaticResource SectionTitleStyle}" />
|
||||
<Border Style="{StaticResource SettingCardStyle}">
|
||||
<CheckBox Style="{StaticResource ToggleSwitchStyle}" Margin="0,12" IsChecked="{Binding CloseToTray}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="La croix réduit le launcher dans la zone de notification" Style="{StaticResource SettingTitleStyle}" />
|
||||
<TextBlock Text="Au lieu de se fermer, le launcher reste actif près de l'horloge : les téléchargements et les mises à jour continuent. Pour le quitter : clic droit sur son icône › Quitter."
|
||||
Style="{StaticResource SettingDescriptionStyle}" />
|
||||
</StackPanel>
|
||||
</CheckBox>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
|
||||
@@ -198,6 +219,34 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Raccourci du Bureau (action immédiate, indépendante de « Enregistrer ») -->
|
||||
<StackPanel Visibility="{Binding HasShortcutActions, Converter={StaticResource BoolToVisibility}}">
|
||||
<TextBlock Text="RACCOURCI" Style="{StaticResource SectionTitleStyle}" />
|
||||
<Border Style="{StaticResource SettingCardStyle}">
|
||||
<Grid Margin="0,14">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Border Width="38" Height="38" CornerRadius="12" VerticalAlignment="Top"
|
||||
Background="{DynamicResource BadgeBrush}">
|
||||
<Path Width="18" Height="18" Stretch="Uniform"
|
||||
Data="{Binding ShortcutStatusKind, Converter={StaticResource KindToResource}, ConverterParameter=Icon}"
|
||||
Fill="{Binding ShortcutStatusKind, Converter={StaticResource KindToResource}}" />
|
||||
</Border>
|
||||
<StackPanel Grid.Column="1" Margin="14,0,16,0" VerticalAlignment="Center">
|
||||
<TextBlock Text="Raccourci sur le Bureau" Style="{StaticResource SettingTitleStyle}" />
|
||||
<TextBlock Text="{Binding ShortcutStatus}" Style="{StaticResource SettingDescriptionStyle}" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="2" Style="{StaticResource SmallAccentButtonStyle}" VerticalAlignment="Center"
|
||||
Content="{Binding ShortcutButtonText}"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Plus}"
|
||||
Command="{Binding CreateShortcutCommand}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Informations sur le jeu sélectionné -->
|
||||
<TextBlock Text="INFORMATIONS" Style="{StaticResource SectionTitleStyle}" />
|
||||
<Border Style="{StaticResource SettingCardStyle}" Padding="18,14,18,12">
|
||||
@@ -231,10 +280,18 @@
|
||||
ui:IconAssist.Icon="{StaticResource Icon.FolderOpen}"
|
||||
Command="{Binding OpenInstallFolderCommand}"
|
||||
Visibility="{Binding IsInstalled, Converter={StaticResource BoolToVisibility}}" />
|
||||
<Button Style="{StaticResource LinkButtonStyle}"
|
||||
<!-- Journaux du jeu : Player.log de Unity (LocalLow, emplacement fixé par Unity) -->
|
||||
<Button Style="{StaticResource LinkButtonStyle}" Margin="0,0,22,0"
|
||||
FontSize="12.5" Content="Ouvrir le dossier des journaux"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.FileDocument}"
|
||||
ToolTip="{Binding LogsToolTip}" ToolTipService.ShowOnDisabled="True"
|
||||
Command="{Binding OpenLogsCommand}" />
|
||||
<!-- Données du jeu : sauvegardes et réglages (Roaming) -->
|
||||
<Button Style="{StaticResource LinkButtonStyle}"
|
||||
FontSize="12.5" Content="Ouvrir le dossier des données"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Folder}"
|
||||
ToolTip="{Binding DataToolTip}" ToolTipService.ShowOnDisabled="True"
|
||||
Command="{Binding OpenDataCommand}" />
|
||||
</WrapPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<Window x:Class="GameLauncher.Views.TrayMenuWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:ui="clr-namespace:GameLauncher.Controls"
|
||||
Title="Menu"
|
||||
WindowStyle="None"
|
||||
AllowsTransparency="True"
|
||||
Background="Transparent"
|
||||
ResizeMode="NoResize"
|
||||
ShowInTaskbar="False"
|
||||
ShowActivated="True"
|
||||
Topmost="True"
|
||||
SizeToContent="WidthAndHeight"
|
||||
WindowStartupLocation="Manual"
|
||||
FontFamily="{DynamicResource UiFont}"
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource TextPrimaryBrush}"
|
||||
UseLayoutRounding="True"
|
||||
SnapsToDevicePixels="True"
|
||||
Deactivated="OnDeactivated"
|
||||
PreviewKeyDown="OnPreviewKeyDown">
|
||||
|
||||
<!--
|
||||
Menu de l'icône de la zone de notification (clic droit) : Ouvrir / Quitter.
|
||||
Même apparence que les menus contextuels du launcher (Themes/Controls.xaml), aux couleurs du
|
||||
thème actif. Fenêtre plutôt que ContextMenu : le launcher étant masqué, un ContextMenu ne peut
|
||||
pas capturer la souris et se refermerait aussitôt. Se ferme dès qu'elle perd le premier plan.
|
||||
-->
|
||||
<Window.Resources>
|
||||
<Style x:Key="TrayMenuItemStyle" TargetType="Button">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Left" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="Bd" Background="Transparent" CornerRadius="10" Padding="12,10" Margin="0,1">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="34" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Border x:Name="IconBox" Width="24" Height="24" CornerRadius="7" Background="#14FFFFFF"
|
||||
HorizontalAlignment="Left" VerticalAlignment="Center">
|
||||
<Path x:Name="Icon" Width="13" Height="13" Stretch="Uniform"
|
||||
Fill="{TemplateBinding Foreground}"
|
||||
Data="{Binding Path=(ui:IconAssist.Icon), RelativeSource={RelativeSource TemplatedParent}}" />
|
||||
</Border>
|
||||
<ContentPresenter Grid.Column="1" VerticalAlignment="Center" RecognizesAccessKey="True" />
|
||||
</Grid>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource SurfaceHoverBrush}" />
|
||||
<Setter TargetName="IconBox" Property="Background" Value="{DynamicResource AccentGradientBrush}" />
|
||||
<Setter TargetName="Icon" Property="Fill" Value="{DynamicResource OnAccentBrush}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsKeyboardFocused" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource SurfaceHoverBrush}" />
|
||||
<Setter TargetName="IconBox" Property="Background" Value="{DynamicResource AccentGradientBrush}" />
|
||||
<Setter TargetName="Icon" Property="Fill" Value="{DynamicResource OnAccentBrush}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
</Window.Resources>
|
||||
|
||||
<!-- Marge : place pour l'ombre portée -->
|
||||
<Grid x:Name="Root" Margin="14">
|
||||
<Border CornerRadius="16" Background="{DynamicResource MenuBrush}"
|
||||
BorderBrush="{DynamicResource BorderStrongBrush}" BorderThickness="1">
|
||||
<Border.Effect>
|
||||
<DropShadowEffect BlurRadius="28" ShadowDepth="8" Direction="270" Opacity="0.55" Color="Black" />
|
||||
</Border.Effect>
|
||||
</Border>
|
||||
<StackPanel Margin="6" MinWidth="190">
|
||||
<Button Style="{StaticResource TrayMenuItemStyle}" Content="_Ouvrir" FontWeight="SemiBold"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.WindowRestore}" Click="OnOpenClick" />
|
||||
<Border Height="1" Margin="10,6" Background="{DynamicResource BorderBrush}" />
|
||||
<Button Style="{StaticResource TrayMenuItemStyle}" Content="_Quitter"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.ExitToApp}" Click="OnExitClick" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Interop;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
namespace GameLauncher.Views;
|
||||
|
||||
/// <summary>
|
||||
/// Menu de l'icône de la zone de notification (Ouvrir / Quitter). Se ferme dès qu'il perd le
|
||||
/// premier plan (clic ailleurs), avec Échap, ou après un choix.
|
||||
/// </summary>
|
||||
public partial class TrayMenuWindow : Window
|
||||
{
|
||||
private bool _closing;
|
||||
|
||||
public TrayMenuWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
public event Action? OpenClicked;
|
||||
public event Action? ExitClicked;
|
||||
|
||||
/// <summary>
|
||||
/// Affiche le menu au-dessus et à gauche du curseur (comme les menus de la zone de notification
|
||||
/// de Windows), sans déborder de la zone de travail de l'écran.
|
||||
/// </summary>
|
||||
/// <param name="cursor">Position du curseur, en unités WPF.</param>
|
||||
public void ShowAt(Point cursor)
|
||||
{
|
||||
// Affichage hors écran pour connaître la taille, puis placement.
|
||||
Left = -32000;
|
||||
Top = -32000;
|
||||
Show();
|
||||
UpdateLayout();
|
||||
|
||||
var shadow = Root.Margin;
|
||||
var work = SystemParameters.WorkArea;
|
||||
var left = cursor.X - ActualWidth + shadow.Right;
|
||||
var top = cursor.Y - ActualHeight + shadow.Bottom;
|
||||
Left = Math.Max(work.Left - shadow.Left, Math.Min(left, work.Right - ActualWidth + shadow.Right));
|
||||
Top = Math.Max(work.Top - shadow.Top, Math.Min(top, work.Bottom - ActualHeight + shadow.Bottom));
|
||||
|
||||
// Premier plan : Windows l'autorise au propriétaire de l'icône pendant le clic.
|
||||
var handle = new WindowInteropHelper(this).Handle;
|
||||
if (handle != IntPtr.Zero) SetForegroundWindow(handle);
|
||||
Activate();
|
||||
if (!IsActive) Log.Warn("Menu de l'icône de la zone de notification : premier plan refusé par Windows (Échap ou un clic sur le menu le referme).");
|
||||
}
|
||||
|
||||
private void OnOpenClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
CloseMenu();
|
||||
OpenClicked?.Invoke();
|
||||
}
|
||||
|
||||
private void OnExitClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
CloseMenu();
|
||||
ExitClicked?.Invoke();
|
||||
}
|
||||
|
||||
private void OnDeactivated(object? sender, EventArgs e) => CloseMenu();
|
||||
|
||||
private void OnPreviewKeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key != Key.Escape) return;
|
||||
e.Handled = true;
|
||||
CloseMenu();
|
||||
}
|
||||
|
||||
/// <summary>Fermeture différée : une fenêtre ne peut pas être fermée pendant certains de ses événements.</summary>
|
||||
public void CloseMenu()
|
||||
{
|
||||
if (_closing) return;
|
||||
_closing = true;
|
||||
Dispatcher.BeginInvoke(new Action(Close));
|
||||
}
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool SetForegroundWindow(IntPtr window);
|
||||
}
|
||||
@@ -30,7 +30,12 @@
|
||||
// Thème du launcher pour ce jeu : "default" (violet/cyan) ou "exovival" (couloir sombre, vert).
|
||||
// Facultatif : "icon" (vignette de la liste des jeux) et "background" (fond personnalisé),
|
||||
// chemins d'images relatifs à GameLauncher.exe.
|
||||
"theme": "exovival"
|
||||
"theme": "exovival",
|
||||
|
||||
// Dossier des données du jeu Unity : « Société\Produit » des Player Settings de Unity.
|
||||
// Sauvegardes et réglages : %APPDATA%\<ce dossier> ; journaux de Unity : AppData\LocalLow\<ce dossier>.
|
||||
// Facultatif (par défaut : publisher\gameName).
|
||||
"gameDataFolder": "LanisysGames\\Exovival Zombie Reborn"
|
||||
|
||||
// Plusieurs jeux ? Voir launcher.multi-jeux.example.json.
|
||||
}
|
||||
@@ -8,8 +8,11 @@
|
||||
// s'appliquent à tous les jeux ; chaque jeu peut les redéfinir.
|
||||
// ===================================================================================
|
||||
|
||||
// Identifiant du launcher (dossier %AppData%\<publisher>\<launcherId> : journaux, dernier jeu choisi).
|
||||
// Identifiant du launcher (identifiant de produit pour les mises à jour, instance unique).
|
||||
"launcherId": "LanisysLauncher",
|
||||
// Dossier des données %AppData%\<publisher>\<dataFolder> et clé HKCU\Software\<publisher>\<dataFolder>
|
||||
// (paramètres, journaux, jeux installés). Facultatif : "GameLauncher" par défaut.
|
||||
"dataFolder": "GameLauncher",
|
||||
"launcherName": "Lanisys Launcher",
|
||||
"publisher": "LanisysGames",
|
||||
"websiteUrl": "https://lanisys.fr",
|
||||
@@ -30,6 +33,7 @@
|
||||
"manifestUrl": "https://launcher.lanisys.fr/exovivalzombie/manifest.json",
|
||||
"defaultInstallDirectory": "C:\\LanisysGames\\Exovival Zombie Reborn",
|
||||
"gameExecutable": "Exovival Zombie Reborn.exe",
|
||||
"gameDataFolder": "LanisysGames\\Exovival Zombie Reborn", // « Société\Produit » de Unity (facultatif)
|
||||
"theme": "exovival"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Tests.Infrastructure;
|
||||
|
||||
namespace GameLauncher.Tests.Contracts;
|
||||
|
||||
public class SignedMessagesTests
|
||||
{
|
||||
[Fact]
|
||||
public void Messages_are_domain_separated()
|
||||
{
|
||||
var login = Encoding.UTF8.GetString(SignedMessages.Login("https://a", "s", "c", "n", "k"));
|
||||
var request = Encoding.UTF8.GetString(SignedMessages.Request("https://a", "s", "sid", "post", "/api/x", Array.Empty<byte>(), 1, "n"));
|
||||
var enrollment = Encoding.UTF8.GetString(SignedMessages.Enrollment("https://a", "s", "h", "k"));
|
||||
|
||||
Assert.StartsWith(SignedMessages.LoginHeader + "\n", login);
|
||||
Assert.StartsWith(SignedMessages.RequestHeader + "\n", request);
|
||||
Assert.StartsWith(SignedMessages.EnrollmentHeader + "\n", enrollment);
|
||||
Assert.Contains("method:POST\n", request); // méthode normalisée
|
||||
Assert.Contains("body-sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n", request); // SHA-256 du vide
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_field_containing_a_new_line_is_refused()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => SignedMessages.Login("https://a", "s", "c\nkey:autre", "n", "k"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Origin_is_normalized()
|
||||
{
|
||||
Assert.Equal("https://launcher.lanisys.fr", SignedMessages.NormalizeOrigin(new Uri("https://Launcher.Lanisys.fr/api/")));
|
||||
Assert.Equal("http://127.0.0.1:5080", SignedMessages.NormalizeOrigin(new Uri("http://127.0.0.1:5080/api/v1/info")));
|
||||
}
|
||||
}
|
||||
|
||||
public class AdminKeysTests
|
||||
{
|
||||
[Fact]
|
||||
public void Sign_and_verify_round_trip()
|
||||
{
|
||||
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var spki = key.ExportSubjectPublicKeyInfo();
|
||||
var message = Encoding.UTF8.GetBytes("message");
|
||||
var signature = AdminKeys.Sign(key, message);
|
||||
|
||||
Assert.Equal(AdminKeys.SignatureLength, signature.Length);
|
||||
Assert.True(AdminKeys.Verify(spki, message, signature));
|
||||
Assert.False(AdminKeys.Verify(spki, Encoding.UTF8.GetBytes("messagE"), signature));
|
||||
signature[10] ^= 1;
|
||||
Assert.False(AdminKeys.Verify(spki, message, signature));
|
||||
Assert.False(AdminKeys.Verify(spki, message, signature.AsSpan(0, 63)));
|
||||
Assert.False(AdminKeys.Verify(new byte[] { 1, 2, 3 }, message, signature));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Public_key_is_accepted_as_pem_or_base64()
|
||||
{
|
||||
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var spki = key.ExportSubjectPublicKeyInfo();
|
||||
|
||||
Assert.Equal(spki, AdminKeys.ParsePublicKey(Convert.ToBase64String(spki)));
|
||||
Assert.Equal(spki, AdminKeys.ParsePublicKey(AdminKeys.ToPem(spki)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Other_curves_private_keys_and_garbage_are_refused()
|
||||
{
|
||||
using var p384 = ECDsa.Create(ECCurve.NamedCurves.nistP384);
|
||||
using var p256 = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
using var rsa = RSA.Create(2048);
|
||||
|
||||
Assert.Throws<CryptographicException>(() => AdminKeys.ParsePublicKey(Convert.ToBase64String(p384.ExportSubjectPublicKeyInfo())));
|
||||
Assert.Throws<CryptographicException>(() => AdminKeys.ParsePublicKey(Convert.ToBase64String(rsa.ExportSubjectPublicKeyInfo())));
|
||||
Assert.Throws<CryptographicException>(() => AdminKeys.ParsePublicKey(p256.ExportPkcs8PrivateKeyPem()));
|
||||
Assert.Throws<CryptographicException>(() => AdminKeys.ParsePublicKey("pas une clé"));
|
||||
Assert.Throws<CryptographicException>(() => AdminKeys.ParsePublicKey(""));
|
||||
Assert.False(AdminKeys.Verify(p384.ExportSubjectPublicKeyInfo(), new byte[] { 1 }, new byte[64]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fingerprint_is_compared_whatever_the_formatting()
|
||||
{
|
||||
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var spki = key.ExportSubjectPublicKeyInfo();
|
||||
var fingerprint = AdminKeys.ComputeFingerprint(spki);
|
||||
|
||||
Assert.Matches("^[0-9A-F]{4}( [0-9A-F]{4}){4}$", fingerprint);
|
||||
Assert.True(AdminKeys.FingerprintMatches(spki, fingerprint));
|
||||
Assert.True(AdminKeys.FingerprintMatches(spki, fingerprint.Replace(" ", "").ToLowerInvariant()));
|
||||
Assert.True(AdminKeys.FingerprintMatches(spki, fingerprint.Replace(' ', '-')));
|
||||
Assert.False(AdminKeys.FingerprintMatches(spki, fingerprint[..^1] + (fingerprint[^1] == '0' ? '1' : '0')));
|
||||
Assert.False(AdminKeys.FingerprintMatches(spki, fingerprint[..19]));
|
||||
Assert.False(AdminKeys.FingerprintMatches(spki, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fingerprint_cannot_be_derived_from_the_visible_key_id()
|
||||
{
|
||||
// L'identifiant de clé apparaît dans les listes et les adresses : l'empreinte, qui prouve la
|
||||
// vérification auprès de la personne invitée, ne doit pas pouvoir s'en déduire.
|
||||
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var spki = key.ExportSubjectPublicKeyInfo();
|
||||
var keyId = AdminKeys.ComputeKeyId(spki);
|
||||
var fingerprint = AdminKeys.ComputeFingerprint(spki).Replace(" ", "");
|
||||
|
||||
Assert.False(AdminKeys.FingerprintMatches(spki, keyId));
|
||||
Assert.False(AdminKeys.FingerprintMatches(spki, keyId + "0000"));
|
||||
Assert.False(fingerprint.StartsWith(keyId[..8], StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Key_id_format()
|
||||
{
|
||||
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var id = AdminKeys.ComputeKeyId(key.ExportSubjectPublicKeyInfo());
|
||||
Assert.True(AdminKeys.IsValidKeyId(id));
|
||||
Assert.False(AdminKeys.IsValidKeyId(id.ToUpperInvariant()));
|
||||
Assert.False(AdminKeys.IsValidKeyId(id + "0"));
|
||||
Assert.False(AdminKeys.IsValidKeyId("../../etc/passwd"));
|
||||
}
|
||||
}
|
||||
|
||||
public class InvitationCodesTests
|
||||
{
|
||||
[Fact]
|
||||
public void Generated_codes_are_long_unique_and_normalizable()
|
||||
{
|
||||
var a = InvitationCodes.Generate();
|
||||
var b = InvitationCodes.Generate();
|
||||
|
||||
Assert.NotEqual(a, b);
|
||||
Assert.Matches("^([A-Z2-7]{4}-){7}[A-Z2-7]{4}$", a);
|
||||
Assert.Equal(a.Replace("-", ""), InvitationCodes.Normalize(a));
|
||||
Assert.Equal(a.Replace("-", ""), InvitationCodes.Normalize(a.ToLowerInvariant().Replace("-", " ")));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("ABCD")]
|
||||
[InlineData("ABCD-EFGH-IJKL-MNOP-QRST-UVWX-YZ23-456")] // trop court
|
||||
[InlineData("ABCD-EFGH-IJKL-MNOP-QRST-UVWX-YZ23-4560")] // « 0 » n'existe pas en Base32
|
||||
public void Malformed_codes_are_refused(string code)
|
||||
{
|
||||
Assert.Null(InvitationCodes.Normalize(code));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Code_hash_depends_on_the_code()
|
||||
{
|
||||
var code = InvitationCodes.Normalize(InvitationCodes.Generate())!;
|
||||
Assert.Equal(SignedMessages.HashInvitationCode(code), SignedMessages.HashInvitationCode(code));
|
||||
Assert.NotEqual(SignedMessages.HashInvitationCode(code), SignedMessages.HashInvitationCode(InvitationCodes.Normalize(InvitationCodes.Generate())!));
|
||||
}
|
||||
}
|
||||
|
||||
public class RolesAndTextTests
|
||||
{
|
||||
[Fact]
|
||||
public void Roles_grant_the_expected_permissions()
|
||||
{
|
||||
Assert.All(AdminPermissions.All, p => Assert.True(AdminRoles.Has(AdminRoles.Owner, p)));
|
||||
Assert.False(AdminRoles.Has(AdminRoles.Admin, AdminPermissions.AdminsManage));
|
||||
Assert.True(AdminRoles.Has(AdminRoles.Admin, AdminPermissions.ReleasesPublish));
|
||||
Assert.False(AdminRoles.Has(AdminRoles.Editor, AdminPermissions.AuditRead));
|
||||
Assert.True(AdminRoles.Has(AdminRoles.Editor, AdminPermissions.ChangelogEdit));
|
||||
Assert.Empty(AdminRoles.PermissionsOf("root"));
|
||||
Assert.False(AdminRoles.IsValid("Owner")); // sensible à la casse
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(" Nicolas Deville ", "Nicolas Deville")]
|
||||
[InlineData("Équipe « QA »", "Équipe « QA »")]
|
||||
public void Clean_line_normalizes_spaces(string input, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ApiText.CleanLine(input, 64));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("a\u0000b")]
|
||||
[InlineData("admingnp.exe")] // inversion bidirectionnelle
|
||||
[InlineData("zérolargeur")]
|
||||
public void Clean_line_refuses_control_and_formatting_characters(string input)
|
||||
{
|
||||
Assert.Null(ApiText.CleanLine(input, 64));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clean_line_refuses_too_long_text()
|
||||
{
|
||||
Assert.Null(ApiText.CleanLine(new string('a', 65), 64));
|
||||
Assert.NotNull(ApiText.CleanLine(new string('a', 64), 64));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Base64url_round_trip()
|
||||
{
|
||||
var data = RandomNumberGenerator.GetBytes(33);
|
||||
Assert.True(Base64Url.TryDecode(Base64Url.Encode(data), out var decoded));
|
||||
Assert.Equal(data, decoded);
|
||||
Assert.False(Base64Url.TryDecode("abc+/=", out _));
|
||||
Assert.False(Base64Url.TryDecode("a", out _));
|
||||
}
|
||||
}
|
||||
|
||||
public class AdminKeyFileTests
|
||||
{
|
||||
[Fact]
|
||||
public void Saved_key_can_only_be_loaded_with_the_passphrase()
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), "launcher-keyfile-tests", Guid.NewGuid().ToString("N"));
|
||||
var path = Path.Combine(directory, "admin.key");
|
||||
try
|
||||
{
|
||||
using var key = AdminKeyFile.Generate();
|
||||
var publicPath = AdminKeyFile.Save(key, path, "phrase de passe solide");
|
||||
|
||||
Assert.Contains("ENCRYPTED PRIVATE KEY", File.ReadAllText(path));
|
||||
Assert.Equal(key.ExportSubjectPublicKeyInfo(), AdminKeys.ParsePublicKey(File.ReadAllText(publicPath)));
|
||||
using (var loaded = AdminKeyFile.Load(path, "phrase de passe solide"))
|
||||
Assert.Equal(key.ExportSubjectPublicKeyInfo(), loaded.ExportSubjectPublicKeyInfo());
|
||||
|
||||
Assert.Throws<CryptographicException>(() => AdminKeyFile.Load(path, "mauvaise phrase de passe"));
|
||||
Assert.Throws<IOException>(() => AdminKeyFile.Save(key, path, "phrase de passe solide")); // jamais d'écrasement
|
||||
Assert.Throws<ArgumentException>(() => AdminKeyFile.Save(key, path + "2", "courte"));
|
||||
Assert.Throws<CryptographicException>(() => AdminKeyFile.Load(publicPath, "phrase de passe solide"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(directory)) Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Client_refuses_plain_http_except_locally()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new LauncherApiClient(new Uri("http://launcher.lanisys.fr/api/"), "tests"));
|
||||
Assert.Throws<ArgumentException>(() => new LauncherApiClient(new Uri("https://launcher.lanisys.fr/api/?x=1"), "tests"));
|
||||
using var local = new LauncherApiClient(new Uri("http://127.0.0.1:5080/api"), "tests");
|
||||
Assert.Equal(new Uri("http://127.0.0.1:5080/api/"), local.BaseUrl);
|
||||
using var remote = new LauncherApiClient(new Uri("https://launcher.lanisys.fr/api/"), "tests");
|
||||
Assert.Equal("https://launcher.lanisys.fr", remote.Origin);
|
||||
_ = TestApi.NewKey(); // la fabrique de clés de test produit bien une clé P-256
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("//evil.example/x")]
|
||||
[InlineData("https://evil.example/api/v1/info")]
|
||||
[InlineData("/v1/info")]
|
||||
[InlineData("../autre/v1/info")]
|
||||
[InlineData("v1/../../autre")]
|
||||
public async Task Token_is_never_sent_outside_the_api(string route)
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var owner = api.AddOwner();
|
||||
await using var session = await api.LoginAsync(owner);
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => session.GetAsync<object>(route));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
Tests automatisés (Visual Studio : Test › Explorateur de tests › Exécuter tout).
|
||||
• Contracts : messages signés, clés, empreintes, codes d'invitation, rôles, textes.
|
||||
• Server : la Launcher API est démarrée en mémoire (port local aléatoire, dossier temporaire)
|
||||
et attaquée comme le ferait un client réel ou malveillant.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RootNamespace>GameLauncher.Tests</RootNamespace>
|
||||
<AssemblyName>GameLauncher.Tests</AssemblyName>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<Platforms>AnyCPU;x64</Platforms>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
<ProjectReference Include="..\..\src\GameLauncher.Contracts\GameLauncher.Contracts.csproj" />
|
||||
<ProjectReference Include="..\..\src\GameLauncher.ApiClient\GameLauncher.ApiClient.csproj" />
|
||||
<ProjectReference Include="..\..\server\GameLauncher.Server\GameLauncher.Server.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,219 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Server.Cli;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace GameLauncher.Tests.Infrastructure;
|
||||
|
||||
/// <summary>Horloge du serveur pilotée par le test (expiration des défis, sessions, invitations).</summary>
|
||||
public sealed class ManualTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now = DateTimeOffset.UtcNow;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
|
||||
public void Advance(TimeSpan delta) => _now += delta;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Launcher API démarrée en mémoire sur un port local libre, avec un dossier de données temporaire
|
||||
/// supprimé à la fin du test.
|
||||
/// </summary>
|
||||
public sealed class TestApi : IAsyncDisposable
|
||||
{
|
||||
private readonly WebApplication _app;
|
||||
|
||||
private TestApi(WebApplication app, string dataDirectory, Uri baseUrl, ManualTimeProvider time)
|
||||
{
|
||||
_app = app;
|
||||
DataDirectory = dataDirectory;
|
||||
BaseUrl = baseUrl;
|
||||
Time = time;
|
||||
Http = new HttpClient { BaseAddress = baseUrl };
|
||||
}
|
||||
|
||||
public string DataDirectory { get; }
|
||||
public Uri BaseUrl { get; }
|
||||
public string Origin => SignedMessages.NormalizeOrigin(BaseUrl);
|
||||
public ManualTimeProvider Time { get; }
|
||||
|
||||
/// <summary>Client HTTP brut (requêtes forgées à la main).</summary>
|
||||
public HttpClient Http { get; }
|
||||
|
||||
public IServiceProvider Services => _app.Services;
|
||||
|
||||
public static async Task<TestApi> StartAsync(IDictionary<string, string?>? settings = null)
|
||||
{
|
||||
var port = FreePort();
|
||||
var dataDirectory = Path.Combine(Path.GetTempPath(), "launcher-api-tests", Guid.NewGuid().ToString("N"));
|
||||
var url = $"http://127.0.0.1:{port}";
|
||||
var time = new ManualTimeProvider();
|
||||
|
||||
var values = new Dictionary<string, string?>
|
||||
{
|
||||
["LauncherApi:PublicOrigin"] = url,
|
||||
["LauncherApi:DataDirectory"] = dataDirectory,
|
||||
["LauncherApi:RequireHttps"] = "false",
|
||||
["LauncherApi:Limits:RequestsPerMinute"] = "100000",
|
||||
["LauncherApi:Limits:AuthPerMinute"] = "100000",
|
||||
["LauncherApi:Limits:EnrollmentsPerHour"] = "100000"
|
||||
};
|
||||
foreach (var (key, value) in settings ?? new Dictionary<string, string?>()) values[key] = value;
|
||||
|
||||
var app = ServerApp.Build(Array.Empty<string>(), builder =>
|
||||
{
|
||||
builder.Configuration.AddInMemoryCollection(values);
|
||||
builder.WebHost.UseUrls(url);
|
||||
builder.Logging.ClearProviders();
|
||||
builder.Services.AddSingleton<TimeProvider>(time);
|
||||
});
|
||||
await app.StartAsync();
|
||||
return new TestApi(app, dataDirectory, new Uri(url + "/api/"), time);
|
||||
}
|
||||
|
||||
public LauncherApiClient CreateClient(Uri? baseUrl = null) => new(baseUrl ?? BaseUrl, "GameLauncher.Tests");
|
||||
|
||||
public static EcdsaAdminSigner NewKey() => new(ECDsa.Create(ECCurve.NamedCurves.nistP256));
|
||||
|
||||
/// <summary>Crée un propriétaire avec l'outil en ligne de commande (comme sur le VPS).</summary>
|
||||
public EcdsaAdminSigner AddOwner(string name = "Propriétaire")
|
||||
{
|
||||
var key = NewKey();
|
||||
var output = new StringWriter();
|
||||
var code = AdminCli.Run(new[] { "add-owner", "--name", name, "--public-key", Convert.ToBase64String(key.PublicKey) },
|
||||
output, DataDirectory);
|
||||
Assert.True(code == 0, output.ToString());
|
||||
return key;
|
||||
}
|
||||
|
||||
public async Task<AdminSession> LoginAsync(IAdminSigner key) => await CreateClient().LoginAsync(key, "tests");
|
||||
|
||||
/// <summary>
|
||||
/// Invitation → enrôlement → approbation : renvoie la clé du nouvel administrateur (active).
|
||||
/// </summary>
|
||||
public async Task<(EcdsaAdminSigner Key, Guid AdminId)> AddAdminAsync(AdminSession owner, string name, string role)
|
||||
{
|
||||
var invitation = await owner.CreateInvitationAsync(new CreateInvitationRequest { DisplayName = name, Role = role });
|
||||
var key = NewKey();
|
||||
var enrolled = await CreateClient().EnrollAsync(invitation.Code, key, "poste de test");
|
||||
await owner.ApproveKeyAsync(enrolled.AdminId, key.KeyId, enrolled.Fingerprint);
|
||||
return (key, enrolled.AdminId);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Requêtes forgées
|
||||
|
||||
public async Task<ServerInfo> InfoAsync() =>
|
||||
(await Http.GetFromJsonAsync<ServerInfo>(ApiRoutes.Info, ApiJson.Options))!;
|
||||
|
||||
/// <summary>Connexion « à la main » : renvoie le jeton (que <see cref="AdminSession"/> ne divulgue pas).</summary>
|
||||
public async Task<LoginResponse> RawLoginAsync(IAdminSigner key)
|
||||
{
|
||||
var challenge = await PostJsonAsync<ChallengeResponse>(ApiRoutes.AuthChallenge, new ChallengeRequest { KeyId = key.KeyId });
|
||||
var message = SignedMessages.Login(Origin, challenge.ServerId, challenge.ChallengeId, challenge.Nonce, key.KeyId);
|
||||
return await PostJsonAsync<LoginResponse>(ApiRoutes.AuthLogin, new LoginRequest
|
||||
{
|
||||
ChallengeId = challenge.ChallengeId,
|
||||
KeyId = key.KeyId,
|
||||
Signature = Convert.ToBase64String(await key.SignAsync(message))
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<T> PostJsonAsync<T>(string route, object body)
|
||||
{
|
||||
using var response = await Http.PostAsync(route, Json(body));
|
||||
Assert.True(response.IsSuccessStatusCode, $"{route} : {(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");
|
||||
return (await response.Content.ReadFromJsonAsync<T>(ApiJson.Options))!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Requête signée forgée : chaque élément (horodatage, nonce, corps envoyé) peut être altéré
|
||||
/// pour vérifier que le serveur refuse.
|
||||
/// </summary>
|
||||
public async Task<HttpResponseMessage> SendSignedRawAsync(LoginResponse session, IAdminSigner key, HttpMethod method, string route,
|
||||
object? body, long? timestamp = null, string? nonce = null, byte[]? sentBodyOverride = null, bool includeSignature = true)
|
||||
{
|
||||
var info = await InfoAsync();
|
||||
var bytes = body is null ? Array.Empty<byte>() : JsonSerializer.SerializeToUtf8Bytes(body, ApiJson.Options);
|
||||
var ts = timestamp ?? Time.GetUtcNow().ToUnixTimeSeconds();
|
||||
var n = nonce ?? Base64Url.Encode(RandomNumberGenerator.GetBytes(16));
|
||||
var path = new Uri(BaseUrl, route).PathAndQuery;
|
||||
var signature = await key.SignAsync(SignedMessages.Request(Origin, info.ServerId, session.SessionId, method.Method, path, bytes, ts, n));
|
||||
|
||||
var request = new HttpRequestMessage(method, route);
|
||||
var sent = sentBodyOverride ?? bytes;
|
||||
if (sent.Length > 0)
|
||||
{
|
||||
request.Content = new ByteArrayContent(sent);
|
||||
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
|
||||
}
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", session.Token);
|
||||
if (includeSignature)
|
||||
{
|
||||
request.Headers.Add(SignedRequestHeaders.Timestamp, ts.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
request.Headers.Add(SignedRequestHeaders.Nonce, n);
|
||||
request.Headers.Add(SignedRequestHeaders.Signature, Convert.ToBase64String(signature));
|
||||
}
|
||||
return await Http.SendAsync(request);
|
||||
}
|
||||
|
||||
public static async Task<ApiError> ReadErrorAsync(HttpResponseMessage response) =>
|
||||
(await response.Content.ReadFromJsonAsync<ApiError>(ApiJson.Options))!;
|
||||
|
||||
public static StringContent Json(object body) =>
|
||||
new(JsonSerializer.Serialize(body, ApiJson.Options), Encoding.UTF8, "application/json");
|
||||
|
||||
private static int FreePort()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
listener.Stop();
|
||||
return port;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
Http.Dispose();
|
||||
await _app.StopAsync();
|
||||
await _app.DisposeAsync();
|
||||
try
|
||||
{
|
||||
Directory.Delete(DataDirectory, recursive: true);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// fichier encore ouvert (antivirus…) : dossier temporaire, sans importance
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Se fait passer pour la clé d'un autre (même identifiant, même clé publique) mais signe avec la sienne.</summary>
|
||||
public sealed class ImpostorSigner : IAdminSigner
|
||||
{
|
||||
private readonly IAdminSigner _victim;
|
||||
private readonly IAdminSigner _attacker;
|
||||
|
||||
public ImpostorSigner(IAdminSigner victim, IAdminSigner attacker)
|
||||
{
|
||||
_victim = victim;
|
||||
_attacker = attacker;
|
||||
}
|
||||
|
||||
public byte[] PublicKey => _victim.PublicKey;
|
||||
public string KeyId => _victim.KeyId;
|
||||
public ValueTask<byte[]> SignAsync(byte[] message, CancellationToken ct = default) => _attacker.SignAsync(message, ct);
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
using System.Net;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Tests.Infrastructure;
|
||||
|
||||
namespace GameLauncher.Tests.Server;
|
||||
|
||||
/// <summary>Invitations, enrôlement, approbation par empreinte, rôles, révocation, garde-fous.</summary>
|
||||
public class AdminManagementTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Invitation_enrollment_and_approval_by_fingerprint()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var ownerKey = api.AddOwner("Nicolas");
|
||||
await using var owner = await api.LoginAsync(ownerKey);
|
||||
|
||||
var invitation = await owner.CreateInvitationAsync(new CreateInvitationRequest { DisplayName = "Paul", Role = AdminRoles.Editor });
|
||||
Assert.Matches("^([A-Z2-7]{4}-){7}[A-Z2-7]{4}$", invitation.Code);
|
||||
Assert.Single(await owner.ListInvitationsAsync());
|
||||
|
||||
using var paulKey = TestApi.NewKey();
|
||||
var enrolled = await api.CreateClient().EnrollAsync(invitation.Code.ToLowerInvariant(), paulKey, "PC de Paul");
|
||||
Assert.Equal(AdminStatus.Pending, enrolled.Status);
|
||||
Assert.Equal(paulKey.Fingerprint, enrolled.Fingerprint);
|
||||
Assert.Empty(await owner.ListInvitationsAsync()); // code consommé
|
||||
|
||||
// En attente : connexion impossible, et l'empreinte n'est PAS affichée au propriétaire.
|
||||
await Assert.ThrowsAsync<ApiException>(() => api.LoginAsync(paulKey));
|
||||
var pending = (await owner.ListAdminsAsync()).Single(a => a.Id == enrolled.AdminId);
|
||||
Assert.Equal(AdminStatus.Pending, pending.Status);
|
||||
Assert.Null(pending.Keys.Single().Fingerprint);
|
||||
|
||||
// Mauvaise empreinte : refusée.
|
||||
var wrong = await Assert.ThrowsAsync<ApiException>(() =>
|
||||
owner.ApproveKeyAsync(enrolled.AdminId, paulKey.KeyId, "0000 0000 0000 0000 0000"));
|
||||
Assert.Equal(ApiErrorCodes.FingerprintMismatch, wrong.Code);
|
||||
|
||||
// L'identifiant de la clé, visible dans la liste, ne permet pas d'approuver sans la personne invitée.
|
||||
wrong = await Assert.ThrowsAsync<ApiException>(() =>
|
||||
owner.ApproveKeyAsync(enrolled.AdminId, paulKey.KeyId, pending.Keys.Single().KeyId + "0000"));
|
||||
Assert.Equal(ApiErrorCodes.FingerprintMismatch, wrong.Code);
|
||||
|
||||
var approved = await owner.ApproveKeyAsync(enrolled.AdminId, paulKey.KeyId, enrolled.Fingerprint.ToLowerInvariant());
|
||||
Assert.Equal(AdminStatus.Active, approved.Status);
|
||||
Assert.Equal(paulKey.Fingerprint, approved.Keys.Single().Fingerprint);
|
||||
|
||||
await using var paul = await api.LoginAsync(paulKey);
|
||||
Assert.Equal(AdminRoles.Editor, paul.Profile.Role);
|
||||
Assert.False(paul.Has(AdminPermissions.AdminsManage));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invitation_code_is_single_use_and_expires()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var ownerKey = api.AddOwner();
|
||||
await using var owner = await api.LoginAsync(ownerKey);
|
||||
|
||||
var used = await owner.CreateInvitationAsync(new CreateInvitationRequest { DisplayName = "A", Role = AdminRoles.Editor });
|
||||
using var first = TestApi.NewKey();
|
||||
using var second = TestApi.NewKey();
|
||||
await api.CreateClient().EnrollAsync(used.Code, first, null);
|
||||
var reuse = await Assert.ThrowsAsync<ApiException>(() => api.CreateClient().EnrollAsync(used.Code, second, null));
|
||||
Assert.Equal(ApiErrorCodes.InvitationInvalid, reuse.Code);
|
||||
|
||||
var expiring = await owner.CreateInvitationAsync(new CreateInvitationRequest { DisplayName = "B", Role = AdminRoles.Editor });
|
||||
api.Time.Advance(TimeSpan.FromHours(24) + TimeSpan.FromMinutes(1));
|
||||
var expired = await Assert.ThrowsAsync<ApiException>(() => api.CreateClient().EnrollAsync(expiring.Code, second, null));
|
||||
Assert.Equal(ApiErrorCodes.InvitationInvalid, expired.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Enrollment_requires_proof_of_possession_of_the_key()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var ownerKey = api.AddOwner();
|
||||
await using var owner = await api.LoginAsync(ownerKey);
|
||||
var invitation = await owner.CreateInvitationAsync(new CreateInvitationRequest { DisplayName = "Paul", Role = AdminRoles.Editor });
|
||||
using var victim = TestApi.NewKey();
|
||||
using var attacker = TestApi.NewKey();
|
||||
|
||||
// Clé publique de quelqu'un d'autre, preuve signée par l'attaquant : refusée.
|
||||
var error = await Assert.ThrowsAsync<ApiException>(() =>
|
||||
api.CreateClient().EnrollAsync(invitation.Code, new ImpostorSigner(victim, attacker), null));
|
||||
Assert.Equal(ApiErrorCodes.InvitationInvalid, error.Code);
|
||||
Assert.Single(await owner.ListInvitationsAsync()); // le code n'a pas été consommé
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deleted_invitation_can_no_longer_be_used()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var ownerKey = api.AddOwner();
|
||||
await using var owner = await api.LoginAsync(ownerKey);
|
||||
var invitation = await owner.CreateInvitationAsync(new CreateInvitationRequest { DisplayName = "Paul", Role = AdminRoles.Admin });
|
||||
|
||||
await owner.DeleteInvitationAsync(invitation.Invitation.Id);
|
||||
using var key = TestApi.NewKey();
|
||||
await Assert.ThrowsAsync<ApiException>(() => api.CreateClient().EnrollAsync(invitation.Code, key, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invitation_input_is_validated()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var ownerKey = api.AddOwner();
|
||||
await using var owner = await api.LoginAsync(ownerKey);
|
||||
|
||||
var badRole = await Assert.ThrowsAsync<ApiException>(() =>
|
||||
owner.CreateInvitationAsync(new CreateInvitationRequest { DisplayName = "Paul", Role = "root" }));
|
||||
Assert.Equal(400, badRole.Status);
|
||||
await Assert.ThrowsAsync<ApiException>(() =>
|
||||
owner.CreateInvitationAsync(new CreateInvitationRequest { DisplayName = "Paul", Role = AdminRoles.Editor }));
|
||||
await Assert.ThrowsAsync<ApiException>(() =>
|
||||
owner.CreateInvitationAsync(new CreateInvitationRequest { DisplayName = new string('x', 65), Role = AdminRoles.Editor }));
|
||||
var unknown = await Assert.ThrowsAsync<ApiException>(() =>
|
||||
owner.CreateInvitationAsync(new CreateInvitationRequest { ForAdminId = Guid.NewGuid() }));
|
||||
Assert.Equal(404, unknown.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Permissions_are_enforced_by_the_server()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var ownerKey = api.AddOwner();
|
||||
await using var owner = await api.LoginAsync(ownerKey);
|
||||
var (editorKey, _) = await api.AddAdminAsync(owner, "Éditeur", AdminRoles.Editor);
|
||||
var (adminKey, _) = await api.AddAdminAsync(owner, "Admin", AdminRoles.Admin);
|
||||
await using var editor = await api.LoginAsync(editorKey);
|
||||
await using var admin = await api.LoginAsync(adminKey);
|
||||
|
||||
Assert.Equal(403, (await Assert.ThrowsAsync<ApiException>(() => editor.ListAdminsAsync())).Status);
|
||||
Assert.Equal(403, (await Assert.ThrowsAsync<ApiException>(() => editor.GetAuditAsync())).Status);
|
||||
Assert.Equal(403, (await Assert.ThrowsAsync<ApiException>(() =>
|
||||
editor.CreateInvitationAsync(new CreateInvitationRequest { DisplayName = "X", Role = AdminRoles.Owner }))).Status);
|
||||
Assert.Equal(403, (await Assert.ThrowsAsync<ApiException>(() => admin.ListAdminsAsync())).Status);
|
||||
await admin.GetAuditAsync(); // l'administrateur lit l'audit
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Role_change_applies_immediately_to_open_sessions()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var ownerKey = api.AddOwner();
|
||||
await using var owner = await api.LoginAsync(ownerKey);
|
||||
var (editorKey, editorId) = await api.AddAdminAsync(owner, "Éditeur", AdminRoles.Editor);
|
||||
await using var editor = await api.LoginAsync(editorKey);
|
||||
await Assert.ThrowsAsync<ApiException>(() => editor.GetAuditAsync());
|
||||
|
||||
await owner.ChangeRoleAsync(editorId, AdminRoles.Admin);
|
||||
await editor.GetAuditAsync();
|
||||
Assert.Equal(AdminRoles.Admin, (await editor.RefreshAsync()).Admin.Role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Revoking_an_admin_closes_its_sessions_and_keys()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var ownerKey = api.AddOwner();
|
||||
await using var owner = await api.LoginAsync(ownerKey);
|
||||
var (editorKey, editorId) = await api.AddAdminAsync(owner, "Éditeur", AdminRoles.Editor);
|
||||
await using var editor = await api.LoginAsync(editorKey);
|
||||
|
||||
var revoked = await owner.RevokeAdminAsync(editorId);
|
||||
Assert.Equal(AdminStatus.Revoked, revoked.Status);
|
||||
Assert.All(revoked.Keys, k => Assert.Equal(AdminStatus.Revoked, k.Status));
|
||||
await Assert.ThrowsAsync<ApiException>(() => editor.RefreshAsync());
|
||||
await Assert.ThrowsAsync<ApiException>(() => api.LoginAsync(editorKey));
|
||||
|
||||
// Un compte révoqué ne peut plus recevoir d'invitation « nouvel appareil ».
|
||||
await Assert.ThrowsAsync<ApiException>(() => owner.CreateInvitationAsync(new CreateInvitationRequest { ForAdminId = editorId }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task New_device_is_approved_by_the_account_holder_only()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var ownerKey = api.AddOwner();
|
||||
await using var owner = await api.LoginAsync(ownerKey);
|
||||
var (laptopKey, adminId) = await api.AddAdminAsync(owner, "Paul", AdminRoles.Admin);
|
||||
await using var laptop = await api.LoginAsync(laptopKey);
|
||||
|
||||
// Paul (sans admins.manage) crée lui-même l'invitation pour son nouveau PC.
|
||||
var invitation = await laptop.CreateInvitationAsync(new CreateInvitationRequest { ForAdminId = adminId });
|
||||
Assert.Equal("Paul", invitation.Invitation.DisplayName);
|
||||
using var desktopKey = TestApi.NewKey();
|
||||
var enrolled = await api.CreateClient().EnrollAsync(invitation.Code, desktopKey, "PC fixe");
|
||||
Assert.Equal(adminId, enrolled.AdminId);
|
||||
await Assert.ThrowsAsync<ApiException>(() => api.LoginAsync(desktopKey));
|
||||
|
||||
// Même un propriétaire, même avec la bonne empreinte, ne peut pas ajouter une clé au nom de Paul.
|
||||
var denied = await Assert.ThrowsAsync<ApiException>(() => owner.ApproveKeyAsync(adminId, desktopKey.KeyId, enrolled.Fingerprint));
|
||||
Assert.Equal(403, denied.Status);
|
||||
// Paul ne peut pas créer d'invitation pour quelqu'un d'autre.
|
||||
Assert.Equal(403, (await Assert.ThrowsAsync<ApiException>(() =>
|
||||
laptop.CreateInvitationAsync(new CreateInvitationRequest { DisplayName = "X", Role = AdminRoles.Owner }))).Status);
|
||||
|
||||
await laptop.ApproveKeyAsync(adminId, desktopKey.KeyId, enrolled.Fingerprint);
|
||||
await using var desktop = await api.LoginAsync(desktopKey);
|
||||
Assert.Equal(AdminRoles.Admin, desktop.Profile.Role);
|
||||
|
||||
// Poste perdu : seule sa clé est révoquée.
|
||||
await owner.RevokeKeyAsync(adminId, laptopKey.KeyId);
|
||||
await Assert.ThrowsAsync<ApiException>(() => api.LoginAsync(laptopKey));
|
||||
await desktop.RefreshAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Role_changes_do_not_let_an_owner_add_a_device_to_someone_else()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var firstKey = api.AddOwner("Nicolas");
|
||||
using var secondKey = api.AddOwner("Second");
|
||||
await using var first = await api.LoginAsync(firstKey);
|
||||
await using var second = await api.LoginAsync(secondKey);
|
||||
|
||||
// Rétrograder l'autre propriétaire, lui ajouter un appareil, puis le promouvoir : bloqué à l'approbation.
|
||||
await first.ChangeRoleAsync(second.Profile.Id, AdminRoles.Editor);
|
||||
var invitation = await first.CreateInvitationAsync(new CreateInvitationRequest { ForAdminId = second.Profile.Id });
|
||||
using var rogueKey = TestApi.NewKey();
|
||||
var enrolled = await api.CreateClient().EnrollAsync(invitation.Code, rogueKey, "pas à lui");
|
||||
Assert.Equal(403, (await Assert.ThrowsAsync<ApiException>(() =>
|
||||
first.ApproveKeyAsync(second.Profile.Id, rogueKey.KeyId, enrolled.Fingerprint))).Status);
|
||||
await Assert.ThrowsAsync<ApiException>(() => api.LoginAsync(rogueKey));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Access_of_an_admin_who_lost_every_device_is_restored_on_the_server_only()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var ownerKey = api.AddOwner();
|
||||
await using var owner = await api.LoginAsync(ownerKey);
|
||||
var (lostKey, adminId) = await api.AddAdminAsync(owner, "Paul", AdminRoles.Editor);
|
||||
await owner.RevokeKeyAsync(adminId, lostKey.KeyId); // seul appareil perdu
|
||||
|
||||
// Par l'API, même un propriétaire ne peut pas ajouter une clé à ce compte (il pourrait y mettre la sienne).
|
||||
var invitation = await owner.CreateInvitationAsync(new CreateInvitationRequest { ForAdminId = adminId });
|
||||
using var newKey = TestApi.NewKey();
|
||||
var enrolled = await api.CreateClient().EnrollAsync(invitation.Code, newKey, "nouveau PC");
|
||||
Assert.Equal(409, (await Assert.ThrowsAsync<ApiException>(() =>
|
||||
owner.ApproveKeyAsync(adminId, newKey.KeyId, enrolled.Fingerprint))).Status);
|
||||
|
||||
// Sur le VPS (accès SSH) : restauration avec une nouvelle clé.
|
||||
using var restoredKey = TestApi.NewKey();
|
||||
Assert.Equal(0, GameLauncher.Server.Cli.AdminCli.Run(new[] { "add-key", "--admin", adminId.ToString(), "--public-key",
|
||||
Convert.ToBase64String(restoredKey.PublicKey) }, TextWriter.Null, api.DataDirectory));
|
||||
await using var paul = await api.LoginAsync(restoredKey);
|
||||
Assert.Equal("Paul", paul.Profile.DisplayName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admins_without_management_rights_have_few_invitations()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var ownerKey = api.AddOwner();
|
||||
await using var owner = await api.LoginAsync(ownerKey);
|
||||
var (key, id) = await api.AddAdminAsync(owner, "Paul", AdminRoles.Editor);
|
||||
await using var paul = await api.LoginAsync(key);
|
||||
|
||||
await paul.CreateInvitationAsync(new CreateInvitationRequest { ForAdminId = id });
|
||||
await paul.CreateInvitationAsync(new CreateInvitationRequest { ForAdminId = id });
|
||||
Assert.Equal(409, (await Assert.ThrowsAsync<ApiException>(() =>
|
||||
paul.CreateInvitationAsync(new CreateInvitationRequest { ForAdminId = id }))).Status);
|
||||
await owner.CreateInvitationAsync(new CreateInvitationRequest { DisplayName = "Autre", Role = AdminRoles.Editor });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Owners_cannot_lock_themselves_out()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var ownerKey = api.AddOwner();
|
||||
await using var owner = await api.LoginAsync(ownerKey);
|
||||
var selfId = owner.Profile.Id;
|
||||
|
||||
Assert.Equal(ApiErrorCodes.SelfAction, (await Assert.ThrowsAsync<ApiException>(() => owner.RevokeAdminAsync(selfId))).Code);
|
||||
Assert.Equal(ApiErrorCodes.SelfAction, (await Assert.ThrowsAsync<ApiException>(() =>
|
||||
owner.ChangeRoleAsync(selfId, AdminRoles.Editor))).Code);
|
||||
Assert.Equal(ApiErrorCodes.SelfAction, (await Assert.ThrowsAsync<ApiException>(() =>
|
||||
owner.RevokeKeyAsync(selfId, ownerKey.KeyId))).Code);
|
||||
await owner.RefreshAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Approving_twice_or_unknown_keys_is_refused()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var ownerKey = api.AddOwner();
|
||||
await using var owner = await api.LoginAsync(ownerKey);
|
||||
var (key, id) = await api.AddAdminAsync(owner, "Paul", AdminRoles.Editor);
|
||||
|
||||
Assert.Equal(409, (await Assert.ThrowsAsync<ApiException>(() => owner.ApproveKeyAsync(id, key.KeyId, key.Fingerprint))).Status);
|
||||
Assert.Equal(404, (await Assert.ThrowsAsync<ApiException>(() => owner.ApproveKeyAsync(id, "0123456789abcdef", key.Fingerprint))).Status);
|
||||
Assert.Equal(404, (await Assert.ThrowsAsync<ApiException>(() => owner.ApproveKeyAsync(Guid.NewGuid(), key.KeyId, key.Fingerprint))).Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Audit_records_actions_and_pages_without_duplicates()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var ownerKey = api.AddOwner("Nicolas");
|
||||
using var stranger = TestApi.NewKey();
|
||||
await Assert.ThrowsAsync<ApiException>(() => api.LoginAsync(stranger));
|
||||
await using var owner = await api.LoginAsync(ownerKey);
|
||||
for (var i = 0; i < 7; i++)
|
||||
await owner.CreateInvitationAsync(new CreateInvitationRequest { DisplayName = $"Invité {i}", Role = AdminRoles.Editor });
|
||||
|
||||
var all = new List<AuditEntry>();
|
||||
string? cursor = null;
|
||||
do
|
||||
{
|
||||
var page = await owner.GetAuditAsync(3, cursor);
|
||||
Assert.True(page.Entries.Count <= 3);
|
||||
all.AddRange(page.Entries);
|
||||
cursor = page.NextCursor;
|
||||
} while (cursor is not null);
|
||||
|
||||
Assert.Equal(10, all.Count); // add-owner (CLI), connexion refusée, connexion, 7 invitations
|
||||
Assert.Equal("admins.invite", all[0].Action); // plus récent d'abord
|
||||
Assert.Equal("cli.add_owner", all[^1].Action);
|
||||
Assert.Contains(all, e => e.Action == "auth.login" && e.Result == AuditResults.Denied && e.KeyId == stranger.KeyId);
|
||||
Assert.Contains(all, e => e.Action == "auth.login" && e.Result == AuditResults.Ok && e.ActorName == "Nicolas");
|
||||
Assert.Equal(all.Count, all.Distinct().Count());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Server.Cli;
|
||||
using GameLauncher.Tests.Infrastructure;
|
||||
|
||||
namespace GameLauncher.Tests.Server;
|
||||
|
||||
/// <summary>Connexion par défi signé, sessions, déconnexion, révocation.</summary>
|
||||
public class AuthTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Owner_logs_in_and_receives_all_permissions()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var owner = api.AddOwner("Nicolas");
|
||||
|
||||
await using var session = await api.LoginAsync(owner);
|
||||
var me = await session.RefreshAsync();
|
||||
|
||||
Assert.Equal("Nicolas", me.Admin.DisplayName);
|
||||
Assert.Equal(AdminRoles.Owner, me.Admin.Role);
|
||||
Assert.Equal(owner.KeyId, me.Admin.KeyId);
|
||||
Assert.All(AdminPermissions.All, p => Assert.Contains(p, me.Admin.Permissions));
|
||||
Assert.True(me.IdleExpiresAt <= me.ExpiresAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_key_is_refused_with_a_generic_message()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
api.AddOwner();
|
||||
using var stranger = TestApi.NewKey();
|
||||
|
||||
var error = await Assert.ThrowsAsync<ApiException>(() => api.LoginAsync(stranger));
|
||||
Assert.Equal(401, error.Status);
|
||||
Assert.Equal("Connexion refusée.", error.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Signature_made_with_another_key_is_refused()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var owner = api.AddOwner();
|
||||
using var attacker = TestApi.NewKey();
|
||||
|
||||
var error = await Assert.ThrowsAsync<ApiException>(() => api.LoginAsync(new ImpostorSigner(owner, attacker)));
|
||||
Assert.Equal(401, error.Status);
|
||||
Assert.Equal("Connexion refusée.", error.Message); // même message que pour une clé inconnue
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Challenge_is_single_use_and_bound_to_the_key()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var owner = api.AddOwner();
|
||||
using var other = api.AddOwner("Autre");
|
||||
|
||||
var challenge = await api.PostJsonAsync<ChallengeResponse>(ApiRoutes.AuthChallenge, new ChallengeRequest { KeyId = owner.KeyId });
|
||||
var signature = Convert.ToBase64String(await owner.SignAsync(
|
||||
SignedMessages.Login(api.Origin, challenge.ServerId, challenge.ChallengeId, challenge.Nonce, owner.KeyId)));
|
||||
var login = new LoginRequest { ChallengeId = challenge.ChallengeId, KeyId = owner.KeyId, Signature = signature };
|
||||
|
||||
using (var first = await api.Http.PostAsync(ApiRoutes.AuthLogin, TestApi.Json(login)))
|
||||
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
|
||||
using (var replay = await api.Http.PostAsync(ApiRoutes.AuthLogin, TestApi.Json(login)))
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, replay.StatusCode);
|
||||
|
||||
// Défi demandé pour une clé, présenté avec une autre (même correctement signé) : refusé.
|
||||
var challenge2 = await api.PostJsonAsync<ChallengeResponse>(ApiRoutes.AuthChallenge, new ChallengeRequest { KeyId = owner.KeyId });
|
||||
var otherSignature = Convert.ToBase64String(await other.SignAsync(
|
||||
SignedMessages.Login(api.Origin, challenge2.ServerId, challenge2.ChallengeId, challenge2.Nonce, other.KeyId)));
|
||||
using var swapped = await api.Http.PostAsync(ApiRoutes.AuthLogin, TestApi.Json(
|
||||
new LoginRequest { ChallengeId = challenge2.ChallengeId, KeyId = other.KeyId, Signature = otherSignature }));
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, swapped.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Expired_challenge_is_refused()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var owner = api.AddOwner();
|
||||
|
||||
var challenge = await api.PostJsonAsync<ChallengeResponse>(ApiRoutes.AuthChallenge, new ChallengeRequest { KeyId = owner.KeyId });
|
||||
api.Time.Advance(TimeSpan.FromSeconds(61));
|
||||
var signature = Convert.ToBase64String(await owner.SignAsync(
|
||||
SignedMessages.Login(api.Origin, challenge.ServerId, challenge.ChallengeId, challenge.Nonce, owner.KeyId)));
|
||||
using var response = await api.Http.PostAsync(ApiRoutes.AuthLogin, TestApi.Json(
|
||||
new LoginRequest { ChallengeId = challenge.ChallengeId, KeyId = owner.KeyId, Signature = signature }));
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Signature_for_another_server_is_refused()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var owner = api.AddOwner();
|
||||
|
||||
var challenge = await api.PostJsonAsync<ChallengeResponse>(ApiRoutes.AuthChallenge, new ChallengeRequest { KeyId = owner.KeyId });
|
||||
var signature = Convert.ToBase64String(await owner.SignAsync(
|
||||
SignedMessages.Login("https://autre-serveur.fr", challenge.ServerId, challenge.ChallengeId, challenge.Nonce, owner.KeyId)));
|
||||
using var response = await api.Http.PostAsync(ApiRoutes.AuthLogin, TestApi.Json(
|
||||
new LoginRequest { ChallengeId = challenge.ChallengeId, KeyId = owner.KeyId, Signature = signature }));
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Session_expires_after_inactivity()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var owner = api.AddOwner();
|
||||
await using var session = await api.LoginAsync(owner);
|
||||
|
||||
api.Time.Advance(TimeSpan.FromMinutes(19));
|
||||
await session.RefreshAsync(); // activité : la session est prolongée
|
||||
api.Time.Advance(TimeSpan.FromMinutes(19));
|
||||
await session.RefreshAsync();
|
||||
api.Time.Advance(TimeSpan.FromMinutes(21));
|
||||
|
||||
var error = await Assert.ThrowsAsync<ApiException>(() => session.RefreshAsync());
|
||||
Assert.Equal(401, error.Status);
|
||||
Assert.False(session.IsOpen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Session_expires_after_the_maximum_duration_even_when_active()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var owner = api.AddOwner();
|
||||
await using var session = await api.LoginAsync(owner);
|
||||
|
||||
for (var i = 0; i < 47; i++)
|
||||
{
|
||||
api.Time.Advance(TimeSpan.FromMinutes(10));
|
||||
await session.RefreshAsync();
|
||||
}
|
||||
api.Time.Advance(TimeSpan.FromMinutes(10)); // 8 h
|
||||
await Assert.ThrowsAsync<ApiException>(() => session.RefreshAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Logout_invalidates_the_token()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var owner = api.AddOwner();
|
||||
var login = await api.RawLoginAsync(owner);
|
||||
|
||||
using var logout = new HttpRequestMessage(HttpMethod.Post, ApiRoutes.AuthLogout);
|
||||
logout.Headers.Authorization = new AuthenticationHeaderValue("Bearer", login.Token);
|
||||
using (var response = await api.Http.SendAsync(logout))
|
||||
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
|
||||
|
||||
using var me = new HttpRequestMessage(HttpMethod.Get, ApiRoutes.AuthMe);
|
||||
me.Headers.Authorization = new AuthenticationHeaderValue("Bearer", login.Token);
|
||||
using var after = await api.Http.SendAsync(me);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, after.StatusCode);
|
||||
Assert.Equal(ApiErrorCodes.Unauthorized, (await TestApi.ReadErrorAsync(after)).Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Revocation_from_the_command_line_closes_open_sessions_immediately()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var owner = api.AddOwner("Nicolas");
|
||||
using var second = api.AddOwner("Second");
|
||||
await using var session = await api.LoginAsync(second);
|
||||
await session.RefreshAsync();
|
||||
|
||||
var code = AdminCli.Run(new[] { "revoke-key", "--key", second.KeyId }, TextWriter.Null, api.DataDirectory);
|
||||
Assert.Equal(0, code);
|
||||
|
||||
await Assert.ThrowsAsync<ApiException>(() => session.RefreshAsync());
|
||||
await Assert.ThrowsAsync<ApiException>(() => api.LoginAsync(second));
|
||||
await using var stillOk = await api.LoginAsync(owner); // l'autre propriétaire n'est pas concerné
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Owner_added_while_the_server_runs_can_log_in_without_restart()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var first = api.AddOwner();
|
||||
await using (await api.LoginAsync(first)) { } // admins.json lu et mis en cache
|
||||
|
||||
using var late = api.AddOwner("Ajouté ensuite");
|
||||
await using var session = await api.LoginAsync(late);
|
||||
Assert.Equal("Ajouté ensuite", session.Profile.DisplayName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Malformed_requests_are_refused_cleanly()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
|
||||
using var badJson = await api.Http.PostAsync(ApiRoutes.AuthLogin, new StringContent("{pas du json", System.Text.Encoding.UTF8, "application/json"));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, badJson.StatusCode);
|
||||
Assert.Equal(ApiErrorCodes.InvalidRequest, (await TestApi.ReadErrorAsync(badJson)).Code);
|
||||
|
||||
using var badKeyId = await api.Http.PostAsync(ApiRoutes.AuthChallenge, TestApi.Json(new ChallengeRequest { KeyId = "../../etc" }));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, badKeyId.StatusCode);
|
||||
|
||||
using var wrongScheme = new HttpRequestMessage(HttpMethod.Get, ApiRoutes.AuthMe);
|
||||
wrongScheme.Headers.Authorization = new AuthenticationHeaderValue("Basic", "YWRtaW46YWRtaW4=");
|
||||
using var basic = await api.Http.SendAsync(wrongScheme);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, basic.StatusCode);
|
||||
|
||||
using var unknown = await api.Http.GetAsync("v1/inexistant");
|
||||
Assert.Equal(HttpStatusCode.NotFound, unknown.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Client_detects_a_server_announcing_another_origin()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var owner = api.AddOwner();
|
||||
// « localhost » au lieu de « 127.0.0.1 » : même machine, mais origine différente de celle configurée côté serveur.
|
||||
using var client = api.CreateClient(new Uri($"http://localhost:{api.BaseUrl.Port}/api/"));
|
||||
|
||||
var error = await Assert.ThrowsAsync<ApiException>(() => client.LoginAsync(owner));
|
||||
Assert.Equal("origin_mismatch", error.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Info_and_health_are_public()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
var info = await api.InfoAsync();
|
||||
Assert.Equal(32, info.ServerId.Length);
|
||||
Assert.Equal(api.Origin, info.Origin);
|
||||
Assert.Contains(AdminKeys.Algorithm, info.KeyAlgorithms);
|
||||
|
||||
using var health = await api.Http.GetAsync(ApiRoutes.Health);
|
||||
Assert.Equal(HttpStatusCode.OK, health.StatusCode);
|
||||
Assert.Equal("no-store", health.Headers.CacheControl?.ToString());
|
||||
Assert.Contains("nosniff", health.Headers.GetValues("X-Content-Type-Options"));
|
||||
Assert.False(health.Headers.Contains("Server"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
using System.Net;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Server.Cli;
|
||||
using GameLauncher.Server.Storage;
|
||||
using GameLauncher.Tests.Infrastructure;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace GameLauncher.Tests.Server;
|
||||
|
||||
/// <summary>Configuration de production : HTTPS via nginx, limites, fichiers, outil en ligne de commande.</summary>
|
||||
public class HostingTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Https_is_required_except_for_local_health_checks()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync(new Dictionary<string, string?>
|
||||
{
|
||||
["LauncherApi:RequireHttps"] = "true",
|
||||
["LauncherApi:PublicOrigin"] = "https://launcher.example" // en production : https obligatoire
|
||||
});
|
||||
|
||||
using var plain = await api.Http.GetAsync(ApiRoutes.Info);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, plain.StatusCode);
|
||||
Assert.Equal(ApiErrorCodes.HttpsRequired, (await TestApi.ReadErrorAsync(plain)).Code);
|
||||
|
||||
using var health = await api.Http.GetAsync(ApiRoutes.Health);
|
||||
Assert.Equal(HttpStatusCode.OK, health.StatusCode);
|
||||
|
||||
// Requête transmise par nginx (même machine) en HTTPS : acceptée.
|
||||
using var proxied = new HttpRequestMessage(HttpMethod.Get, ApiRoutes.Info);
|
||||
proxied.Headers.Add("X-Forwarded-Proto", "https");
|
||||
proxied.Headers.Add("X-Forwarded-For", "203.0.113.7");
|
||||
using var response = await api.Http.SendAsync(proxied);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Forwarded_headers_are_ignored_when_they_do_not_come_from_a_trusted_proxy()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync(new Dictionary<string, string?>
|
||||
{
|
||||
["LauncherApi:RequireHttps"] = "true",
|
||||
["LauncherApi:PublicOrigin"] = "https://launcher.example",
|
||||
["LauncherApi:TrustedProxies:0"] = "192.0.2.1", // nginx « ailleurs » : 127.0.0.1 n'est plus de confiance
|
||||
["LauncherApi:TrustedProxies:1"] = "192.0.2.2"
|
||||
});
|
||||
|
||||
using var spoofed = new HttpRequestMessage(HttpMethod.Get, ApiRoutes.Info);
|
||||
spoofed.Headers.Add("X-Forwarded-Proto", "https");
|
||||
using var response = await api.Http.SendAsync(spoofed);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Login_attempts_are_rate_limited_per_ip()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync(new Dictionary<string, string?> { ["LauncherApi:Limits:AuthPerMinute"] = "3" });
|
||||
var body = new ChallengeRequest { KeyId = "0123456789abcdef" };
|
||||
|
||||
for (var i = 0; i < 3; i++)
|
||||
using (var ok = await api.Http.PostAsync(ApiRoutes.AuthChallenge, TestApi.Json(body)))
|
||||
Assert.Equal(HttpStatusCode.OK, ok.StatusCode);
|
||||
using var limited = await api.Http.PostAsync(ApiRoutes.AuthChallenge, TestApi.Json(body));
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, limited.StatusCode);
|
||||
Assert.Equal(ApiErrorCodes.RateLimited, (await TestApi.ReadErrorAsync(limited)).Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Oversized_requests_are_refused()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var response = await api.Http.PostAsync(ApiRoutes.AuthLogin,
|
||||
new StringContent("{\"keyId\":\"" + new string('a', 70_000) + "\"}", System.Text.Encoding.UTF8, "application/json"));
|
||||
Assert.Equal(HttpStatusCode.RequestEntityTooLarge, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Flooding_challenges_does_not_block_a_legitimate_login()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var owner = api.AddOwner();
|
||||
var junk = new ChallengeRequest { KeyId = "0123456789abcdef" };
|
||||
|
||||
// Défis sans état : en demander en masse n'occupe aucune réserve partagée.
|
||||
for (var i = 0; i < 50; i++)
|
||||
using (await api.Http.PostAsync(ApiRoutes.AuthChallenge, TestApi.Json(junk))) { }
|
||||
await using var session = await api.LoginAsync(owner);
|
||||
for (var i = 0; i < 50; i++)
|
||||
using (var response = await api.Http.PostAsync(ApiRoutes.AuthChallenge, TestApi.Json(junk)))
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
await session.RefreshAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Flood_of_failed_logins_is_summarized_in_the_audit()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var ownerKey = api.AddOwner();
|
||||
using var stranger = TestApi.NewKey();
|
||||
for (var i = 0; i < 25; i++)
|
||||
await Assert.ThrowsAsync<ApiException>(() => api.LoginAsync(stranger));
|
||||
|
||||
api.Time.Advance(TimeSpan.FromMinutes(1));
|
||||
await using var owner = await api.LoginAsync(ownerKey);
|
||||
api.Time.Advance(TimeSpan.FromMinutes(1));
|
||||
await Assert.ThrowsAsync<ApiException>(() => api.LoginAsync(stranger)); // nouvelle minute : détaillé, + résumé
|
||||
|
||||
var entries = (await owner.GetAuditAsync(200)).Entries;
|
||||
Assert.Equal(GameLauncher.Server.Storage.AuditLog.MaxAnonymousFailuresPerMinute + 1,
|
||||
entries.Count(e => e.Action == "auth.login" && e.Result == AuditResults.Denied));
|
||||
var summary = Assert.Single(entries.Where(e => e.Action == "audit.suppressed"));
|
||||
Assert.Contains("15 tentative", summary.Details);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Use_of_a_revoked_key_is_always_recorded_even_during_a_flood()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var ownerKey = api.AddOwner("Nicolas");
|
||||
using var stolenKey = api.AddOwner("Volé");
|
||||
Assert.Equal(0, AdminCli.Run(new[] { "revoke-key", "--key", stolenKey.KeyId }, TextWriter.Null, api.DataDirectory));
|
||||
using var junk = TestApi.NewKey();
|
||||
for (var i = 0; i < 30; i++) await Assert.ThrowsAsync<ApiException>(() => api.LoginAsync(junk));
|
||||
|
||||
for (var i = 0; i < 5; i++)
|
||||
await Assert.ThrowsAsync<ApiException>(() => api.LoginAsync(stolenKey)); // signature valide, clé révoquée
|
||||
api.Time.Advance(TimeSpan.FromMinutes(1));
|
||||
await Assert.ThrowsAsync<ApiException>(() => api.LoginAsync(stolenKey));
|
||||
await using var owner = await api.LoginAsync(ownerKey);
|
||||
var stolen = (await owner.GetAuditAsync(200)).Entries
|
||||
.Where(e => e.Action == "auth.login" && e.Result == AuditResults.Denied && e.KeyId == stolenKey.KeyId).ToList();
|
||||
Assert.Equal(2, stolen.Count); // une ligne par clé et par minute…
|
||||
Assert.All(stolen, e => Assert.Equal("Volé", e.ActorName));
|
||||
Assert.Contains("+4 refus", stolen[0].Details); // …avec le nombre de refus regroupés
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unreadable_audit_lines_do_not_hide_older_entries()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var ownerKey = api.AddOwner();
|
||||
await using var owner = await api.LoginAsync(ownerKey);
|
||||
for (var i = 0; i < 4; i++) await owner.CreateInvitationAsync(new CreateInvitationRequest { DisplayName = $"Avant {i}", Role = "editor" });
|
||||
var file = Directory.GetFiles(Path.Combine(api.DataDirectory, "audit"), "*.jsonl").Single();
|
||||
await File.AppendAllTextAsync(file, "{ligne tronquée\n" + new string('x', 1_200_000) + "\n");
|
||||
for (var i = 0; i < 4; i++) await owner.CreateInvitationAsync(new CreateInvitationRequest { DisplayName = $"Après {i}", Role = "editor" });
|
||||
|
||||
var all = new List<AuditEntry>();
|
||||
string? cursor = null;
|
||||
do
|
||||
{
|
||||
var page = await owner.GetAuditAsync(3, cursor);
|
||||
all.AddRange(page.Entries);
|
||||
cursor = page.NextCursor;
|
||||
} while (cursor is not null);
|
||||
|
||||
Assert.Equal(10, all.Count(e => e.Action is "admins.invite" or "auth.login" or "cli.add_owner"));
|
||||
Assert.Contains(all, e => e.Details!.Contains("Avant 0"));
|
||||
Assert.Equal("cli.add_owner", all[^1].Action);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Corrupted_admin_file_makes_the_server_refuse_to_start()
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), "launcher-api-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(directory);
|
||||
await File.WriteAllTextAsync(Path.Combine(directory, AdminStore.FileName), "{ ceci n'est pas du json");
|
||||
try
|
||||
{
|
||||
var error = await Assert.ThrowsAsync<InvalidDataException>(() =>
|
||||
TestApi.StartAsync(new Dictionary<string, string?> { ["LauncherApi:DataDirectory"] = directory }));
|
||||
Assert.Contains(".bak", error.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Invalid_configuration_is_refused()
|
||||
{
|
||||
Assert.Throws<InvalidOperationException>(() => GameLauncher.Server.Hosting.ServerApp.Build(Array.Empty<string>(), b =>
|
||||
b.Configuration.AddInMemoryCollection(new Dictionary<string, string?> { ["LauncherApi:PublicOrigin"] = "http://launcher.lanisys.fr" })));
|
||||
Assert.Throws<InvalidOperationException>(() => GameLauncher.Server.Hosting.ServerApp.Build(Array.Empty<string>(), b =>
|
||||
b.Configuration.AddInMemoryCollection(new Dictionary<string, string?> { ["LauncherApi:PublicOrigin"] = "https://launcher.lanisys.fr/api" })));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[System.Runtime.Versioning.UnsupportedOSPlatform("windows")]
|
||||
public async Task Private_files_are_only_readable_by_the_service_account()
|
||||
{
|
||||
if (OperatingSystem.IsWindows()) return; // droits Unix : vérifiés sur le VPS (Linux)
|
||||
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var owner = api.AddOwner();
|
||||
await using (await api.LoginAsync(owner)) { }
|
||||
|
||||
Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute,
|
||||
File.GetUnixFileMode(api.DataDirectory));
|
||||
foreach (var file in new[] { AdminStore.FileName, ServerIdentity.FileName })
|
||||
Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite, File.GetUnixFileMode(Path.Combine(api.DataDirectory, file)));
|
||||
Assert.All(Directory.GetFiles(Path.Combine(api.DataDirectory, "audit"), "*.jsonl"),
|
||||
f => Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite, File.GetUnixFileMode(f)));
|
||||
Assert.DoesNotContain(owner.KeyId + "PRIVATE", await File.ReadAllTextAsync(Path.Combine(api.DataDirectory, AdminStore.FileName)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admin_file_contains_no_secret_and_keeps_a_backup()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var ownerKey = api.AddOwner();
|
||||
await using var owner = await api.LoginAsync(ownerKey);
|
||||
var invitation = await owner.CreateInvitationAsync(new CreateInvitationRequest { DisplayName = "Paul", Role = "editor" });
|
||||
|
||||
var content = await File.ReadAllTextAsync(Path.Combine(api.DataDirectory, AdminStore.FileName));
|
||||
Assert.DoesNotContain(invitation.Code, content);
|
||||
Assert.DoesNotContain(invitation.Code.Replace("-", ""), content);
|
||||
Assert.DoesNotContain("PRIVATE", content);
|
||||
Assert.True(File.Exists(Path.Combine(api.DataDirectory, AdminStore.FileName + ".bak")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Command_line_tool_validates_its_input()
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), "launcher-api-tests", Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
using var key = TestApi.NewKey();
|
||||
var publicKey = Convert.ToBase64String(key.PublicKey);
|
||||
var output = new StringWriter();
|
||||
|
||||
Assert.Equal(0, AdminCli.Run(new[] { "add-owner", "--name", "Nicolas", "--public-key", publicKey }, output, directory));
|
||||
Assert.Contains(key.Fingerprint, output.ToString());
|
||||
Assert.Equal(2, AdminCli.Run(new[] { "add-owner", "--name", "Doublon", "--public-key", publicKey }, TextWriter.Null, directory));
|
||||
Assert.Equal(2, AdminCli.Run(new[] { "add-owner", "--name", "X", "--public-key", "invalide" }, TextWriter.Null, directory));
|
||||
Assert.Equal(2, AdminCli.Run(new[] { "add-owner", "--public-key", publicKey }, TextWriter.Null, directory));
|
||||
Assert.Equal(2, AdminCli.Run(new[] { "revoke-key", "--key", "pas-un-id" }, TextWriter.Null, directory));
|
||||
|
||||
// Commande de lecture sur un dossier sans données (mauvais dossier ?) : refusée, rien n'est créé.
|
||||
var empty = Path.Combine(directory, "vide");
|
||||
Assert.Equal(2, AdminCli.Run(new[] { "revoke-key", "--key", key.KeyId }, TextWriter.Null, empty));
|
||||
Assert.False(Directory.Exists(empty));
|
||||
|
||||
var list = new StringWriter();
|
||||
Assert.Equal(0, AdminCli.Run(new[] { "list" }, list, directory));
|
||||
Assert.Contains("Nicolas", list.ToString());
|
||||
Assert.Contains(key.KeyId, list.ToString());
|
||||
|
||||
Assert.True(AdminCli.IsCommand(new[] { "add-owner" }));
|
||||
Assert.False(AdminCli.IsCommand(new[] { "--urls", "http://127.0.0.1:5080" })); // arguments du serveur
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(directory)) Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Tests.Infrastructure;
|
||||
|
||||
namespace GameLauncher.Tests.Server;
|
||||
|
||||
/// <summary>Toute modification doit être signée par la clé de la session : un jeton volé ne suffit pas.</summary>
|
||||
public class SignedRequestTests
|
||||
{
|
||||
private static readonly CreateInvitationRequest Invitation = new() { DisplayName = "Paul", Role = AdminRoles.Editor };
|
||||
|
||||
[Fact]
|
||||
public async Task Correctly_signed_request_is_accepted()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var owner = api.AddOwner();
|
||||
var session = await api.RawLoginAsync(owner);
|
||||
|
||||
using var response = await api.SendSignedRawAsync(session, owner, HttpMethod.Post, ApiRoutes.Invitations, Invitation);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Token_alone_cannot_modify_anything()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var owner = api.AddOwner();
|
||||
var session = await api.RawLoginAsync(owner);
|
||||
|
||||
using var response = await api.SendSignedRawAsync(session, owner, HttpMethod.Post, ApiRoutes.Invitations, Invitation,
|
||||
includeSignature: false);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
Assert.Equal(ApiErrorCodes.SignatureRequired, (await TestApi.ReadErrorAsync(response)).Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Body_modified_after_signing_is_refused()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var owner = api.AddOwner();
|
||||
var session = await api.RawLoginAsync(owner);
|
||||
var tampered = Encoding.UTF8.GetBytes("""{"displayName":"Paul","role":"owner"}""");
|
||||
|
||||
using var response = await api.SendSignedRawAsync(session, owner, HttpMethod.Post, ApiRoutes.Invitations, Invitation,
|
||||
sentBodyOverride: tampered);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
Assert.Equal(ApiErrorCodes.SignatureInvalid, (await TestApi.ReadErrorAsync(response)).Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Signature_from_another_key_is_refused_even_with_a_valid_token()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var owner = api.AddOwner();
|
||||
using var thief = TestApi.NewKey();
|
||||
var session = await api.RawLoginAsync(owner); // jeton « volé »
|
||||
|
||||
using var response = await api.SendSignedRawAsync(session, thief, HttpMethod.Post, ApiRoutes.Invitations, Invitation);
|
||||
Assert.Equal(ApiErrorCodes.SignatureInvalid, (await TestApi.ReadErrorAsync(response)).Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Replayed_request_is_refused()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var owner = api.AddOwner();
|
||||
var session = await api.RawLoginAsync(owner);
|
||||
var timestamp = api.Time.GetUtcNow().ToUnixTimeSeconds();
|
||||
var nonce = Base64Url.Encode(System.Security.Cryptography.RandomNumberGenerator.GetBytes(16));
|
||||
|
||||
using (var first = await api.SendSignedRawAsync(session, owner, HttpMethod.Post, ApiRoutes.Invitations, Invitation, timestamp, nonce))
|
||||
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
|
||||
using var replay = await api.SendSignedRawAsync(session, owner, HttpMethod.Post, ApiRoutes.Invitations, Invitation, timestamp, nonce);
|
||||
Assert.Equal(ApiErrorCodes.Replay, (await TestApi.ReadErrorAsync(replay)).Code);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-200)]
|
||||
[InlineData(200)]
|
||||
public async Task Request_signed_too_long_ago_or_in_the_future_is_refused(int seconds)
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var owner = api.AddOwner();
|
||||
var session = await api.RawLoginAsync(owner);
|
||||
|
||||
using var response = await api.SendSignedRawAsync(session, owner, HttpMethod.Post, ApiRoutes.Invitations, Invitation,
|
||||
timestamp: api.Time.GetUtcNow().AddSeconds(seconds).ToUnixTimeSeconds());
|
||||
Assert.Equal(ApiErrorCodes.ClockSkew, (await TestApi.ReadErrorAsync(response)).Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Signature_is_bound_to_the_path()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var owner = api.AddOwner();
|
||||
var session = await api.RawLoginAsync(owner);
|
||||
var created = await (await api.SendSignedRawAsync(session, owner, HttpMethod.Post, ApiRoutes.Invitations, Invitation))
|
||||
.Content.ReadFromJsonAsync<InvitationCreated>(ApiJson.Options);
|
||||
|
||||
// Signature valide pour une invitation, rejouée contre une autre adresse : refusée.
|
||||
var info = await api.InfoAsync();
|
||||
var timestamp = api.Time.GetUtcNow().ToUnixTimeSeconds();
|
||||
const string nonce = "AAAAAAAAAAAAAAAAAAAAAA";
|
||||
var signature = await owner.SignAsync(SignedMessages.Request(api.Origin, info.ServerId, session.SessionId, "DELETE",
|
||||
"/api/" + ApiRoutes.Invitation(Guid.NewGuid()), Array.Empty<byte>(), timestamp, nonce));
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Delete, ApiRoutes.Invitation(created!.Invitation.Id));
|
||||
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session.Token);
|
||||
request.Headers.Add(SignedRequestHeaders.Timestamp, timestamp.ToString());
|
||||
request.Headers.Add(SignedRequestHeaders.Nonce, nonce);
|
||||
request.Headers.Add(SignedRequestHeaders.Signature, Convert.ToBase64String(signature));
|
||||
using var response = await api.Http.SendAsync(request);
|
||||
Assert.Equal(ApiErrorCodes.SignatureInvalid, (await TestApi.ReadErrorAsync(response)).Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Client_signs_with_the_server_clock_even_if_the_pc_clock_is_wrong()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var owner = api.AddOwner();
|
||||
api.Time.Advance(TimeSpan.FromMinutes(50)); // l'horloge du serveur et celle du « PC » diffèrent de 50 min
|
||||
|
||||
await using var session = await api.LoginAsync(owner);
|
||||
var created = await session.CreateInvitationAsync(Invitation);
|
||||
Assert.False(string.IsNullOrEmpty(created.Code));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Client_refuses_a_server_clock_off_by_more_than_an_hour()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var owner = api.AddOwner();
|
||||
api.Time.Advance(TimeSpan.FromHours(3));
|
||||
|
||||
var error = await Assert.ThrowsAsync<GameLauncher.ApiClient.ApiException>(() => api.LoginAsync(owner));
|
||||
Assert.Equal("clock_skew", error.Code);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
Outil de l'administrateur (sur VOTRE PC) :
|
||||
create crée une clé d'administrateur (fichier chiffré par une phrase de passe) + sa clé publique
|
||||
login-test vérifie la connexion à la Launcher API avec cette clé
|
||||
enroll enregistre la clé avec un code d'invitation
|
||||
admins, invite, approve, role, revoke-key, revoke-admin, audit : gestion des administrateurs
|
||||
En attendant le menu d'administration du launcher (phase 2), il permet de tout vérifier.
|
||||
Voir server\deploy\LISEZMOI.md.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RootNamespace>AdminKeyTool</RootNamespace>
|
||||
<AssemblyName>AdminKeyTool</AssemblyName>
|
||||
<Platforms>AnyCPU;x64</Platforms>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\GameLauncher.ApiClient\GameLauncher.ApiClient.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,292 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace AdminKeyTool;
|
||||
|
||||
/// <summary>
|
||||
/// Clé d'administrateur et tests de la Launcher API depuis le PC de l'administrateur.
|
||||
///
|
||||
/// AdminKeyTool create --out %USERPROFILE%\.launcher-keys\admin.key [--label "PC bureau"]
|
||||
/// AdminKeyTool login-test --api https://launcher.lanisys.fr/api/ --key …\admin.key
|
||||
/// </summary>
|
||||
public static class Program
|
||||
{
|
||||
private const string DefaultKeyPath = @"%USERPROFILE%\.launcher-keys\admin.key";
|
||||
|
||||
public static async Task<int> Main(string[] args)
|
||||
{
|
||||
Console.OutputEncoding = Encoding.UTF8;
|
||||
if (args.Length == 0 || args[0] is "-h" or "--help" or "help") return Usage();
|
||||
|
||||
try
|
||||
{
|
||||
var o = Options.Parse(args);
|
||||
return args[0].ToLowerInvariant() switch
|
||||
{
|
||||
"create" => Create(o),
|
||||
"fingerprint" => Fingerprint(o),
|
||||
"login-test" => await WithSession(o, LoginTest),
|
||||
"enroll" => await Enroll(o),
|
||||
"admins" => await WithSession(o, ListAdmins),
|
||||
"invite" => await WithSession(o, Invite),
|
||||
"approve" => await WithSession(o, Approve),
|
||||
"role" => await WithSession(o, Role),
|
||||
"revoke-key" => await WithSession(o, RevokeKey),
|
||||
"revoke-admin" => await WithSession(o, RevokeAdmin),
|
||||
"audit" => await WithSession(o, Audit),
|
||||
_ => Usage()
|
||||
};
|
||||
}
|
||||
catch (ApiException ex)
|
||||
{
|
||||
Error($"{ex.Message}" + (ex.Status > 0 ? $" [{ex.Status} {ex.Code}]" : ""));
|
||||
return 3;
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or IOException or CryptographicException or InvalidOperationException
|
||||
or UnauthorizedAccessException)
|
||||
{
|
||||
Error(ex.Message);
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Clé
|
||||
|
||||
private static int Create(Options o)
|
||||
{
|
||||
var path = o.KeyPath;
|
||||
if (File.Exists(path)) throw new IOException($"Une clé existe déjà : {path} (choisissez un autre --out).");
|
||||
Console.WriteLine("Choisissez une phrase de passe (12 caractères minimum). Elle protège la clé : sans elle, le fichier ne sert à rien.");
|
||||
var passphrase = ReadPassphrase(confirm: true);
|
||||
|
||||
using var key = AdminKeyFile.Generate();
|
||||
var publicPath = AdminKeyFile.Save(key, path, passphrase);
|
||||
using var signer = new EcdsaAdminSigner(AdminKeyFile.Load(path, passphrase));
|
||||
Success("Clé d'administrateur créée.");
|
||||
Console.WriteLine($" Clé privée (chiffrée) : {path}");
|
||||
Console.WriteLine($" Clé publique : {publicPath}");
|
||||
Console.WriteLine($" Identifiant : {signer.KeyId}");
|
||||
Console.WriteLine($" Empreinte : {signer.Fingerprint}");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Sauvegardez la clé privée (clé USB, gestionnaire de mots de passe). Ne la mettez jamais dans Git ni sur le serveur.");
|
||||
Console.WriteLine("Premier propriétaire : copiez la clé PUBLIQUE sur le VPS puis lancez « add-owner » (server\\deploy\\LISEZMOI.md).");
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static int Fingerprint(Options o)
|
||||
{
|
||||
var publicPath = File.Exists(Path.ChangeExtension(o.KeyPath, AdminKeyFile.PublicKeyExtension))
|
||||
? Path.ChangeExtension(o.KeyPath, AdminKeyFile.PublicKeyExtension)
|
||||
: o.KeyPath;
|
||||
var spki = AdminKeys.ParsePublicKey(File.ReadAllText(publicPath));
|
||||
Console.WriteLine($"Identifiant : {AdminKeys.ComputeKeyId(spki)}");
|
||||
Console.WriteLine($"Empreinte : {AdminKeys.ComputeFingerprint(spki)}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Connexion
|
||||
|
||||
private static async Task<int> WithSession(Options o, Func<AdminSession, Options, Task<int>> action)
|
||||
{
|
||||
using var signer = UnlockKey(o);
|
||||
using var client = new LauncherApiClient(o.ApiUrl, "AdminKeyTool/1.0");
|
||||
await using var session = await client.LoginAsync(signer, $"AdminKeyTool ({Environment.MachineName})");
|
||||
return await action(session, o);
|
||||
}
|
||||
|
||||
private static async Task<int> LoginTest(AdminSession session, Options o)
|
||||
{
|
||||
var info = await session.RefreshAsync();
|
||||
Success($"Connexion réussie : {info.Admin.DisplayName} ({AdminRoles.DisplayName(info.Admin.Role)})");
|
||||
Console.WriteLine($" Serveur : {session.ServerId}");
|
||||
Console.WriteLine($" Permissions : {string.Join(", ", info.Admin.Permissions)}");
|
||||
Console.WriteLine($" Session : expire au plus tard le {info.ExpiresAt.ToLocalTime():dd/MM/yyyy HH:mm}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static async Task<int> Enroll(Options o)
|
||||
{
|
||||
var code = o.Require("code");
|
||||
using var signer = UnlockKey(o);
|
||||
using var client = new LauncherApiClient(o.ApiUrl, "AdminKeyTool/1.0");
|
||||
var result = await client.EnrollAsync(code, signer, o.Get("label") ?? Environment.MachineName);
|
||||
Success($"Clé enregistrée pour {result.DisplayName}, en attente d'approbation.");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($" Communiquez cette empreinte au propriétaire (par téléphone ou message) : {result.Fingerprint}");
|
||||
Console.WriteLine(" Il doit la saisir pour approuver votre clé.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Administrateurs
|
||||
|
||||
private static async Task<int> ListAdmins(AdminSession session, Options o)
|
||||
{
|
||||
foreach (var admin in await session.ListAdminsAsync())
|
||||
{
|
||||
Console.WriteLine($"{admin.DisplayName} [{AdminRoles.DisplayName(admin.Role)}, {admin.Status}] {admin.Id:D}");
|
||||
foreach (var key in admin.Keys)
|
||||
Console.WriteLine($" clé {key.KeyId} {key.Fingerprint ?? "(empreinte à vérifier)"} {key.Status,-8} {key.Label}");
|
||||
}
|
||||
foreach (var invitation in await session.ListInvitationsAsync())
|
||||
Console.WriteLine($"Invitation {invitation.Id:D} : {invitation.DisplayName} ({invitation.Role}), expire le {invitation.ExpiresAt.ToLocalTime():dd/MM HH:mm}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static async Task<int> Invite(AdminSession session, Options o)
|
||||
{
|
||||
var forAdmin = o.Get("for") is { } id ? Guid.Parse(id) : (Guid?)null;
|
||||
var created = await session.CreateInvitationAsync(new CreateInvitationRequest
|
||||
{
|
||||
DisplayName = forAdmin is null ? o.Require("name") : null,
|
||||
Role = forAdmin is null ? o.Get("role") ?? AdminRoles.Editor : null,
|
||||
ForAdminId = forAdmin
|
||||
});
|
||||
Success($"Invitation créée pour {created.Invitation.DisplayName} ({AdminRoles.DisplayName(created.Invitation.Role)}).");
|
||||
Console.WriteLine($" Code (affiché une seule fois) : {created.Code}");
|
||||
Console.WriteLine($" Valable jusqu'au {created.Invitation.ExpiresAt.ToLocalTime():dd/MM/yyyy HH:mm}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static async Task<int> Approve(AdminSession session, Options o)
|
||||
{
|
||||
var admin = await session.ApproveKeyAsync(Guid.Parse(o.Require("admin")), o.Require("key-id"), o.Require("fingerprint"));
|
||||
Success($"Clé approuvée : {admin.DisplayName} ({admin.Status}).");
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static async Task<int> Role(AdminSession session, Options o)
|
||||
{
|
||||
var admin = await session.ChangeRoleAsync(Guid.Parse(o.Require("admin")), o.Require("role"));
|
||||
Success($"{admin.DisplayName} : {AdminRoles.DisplayName(admin.Role)}.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static async Task<int> RevokeKey(AdminSession session, Options o)
|
||||
{
|
||||
await session.RevokeKeyAsync(Guid.Parse(o.Require("admin")), o.Require("key-id"));
|
||||
Success("Clé révoquée.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static async Task<int> RevokeAdmin(AdminSession session, Options o)
|
||||
{
|
||||
var admin = await session.RevokeAdminAsync(Guid.Parse(o.Require("admin")));
|
||||
Success($"{admin.DisplayName} révoqué.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static async Task<int> Audit(AdminSession session, Options o)
|
||||
{
|
||||
var page = await session.GetAuditAsync(int.TryParse(o.Get("limit"), out var limit) ? limit : 30);
|
||||
foreach (var e in page.Entries)
|
||||
Console.WriteLine($"{e.Timestamp.ToLocalTime():dd/MM HH:mm:ss} {e.Result,-6} {e.Action,-22} {e.ActorName ?? "-",-16} {e.Ip ?? "",-15} {e.Target} {e.Details}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Saisie
|
||||
|
||||
private static EcdsaAdminSigner UnlockKey(Options o) =>
|
||||
new(AdminKeyFile.Load(o.KeyPath, ReadPassphrase(confirm: false)));
|
||||
|
||||
private static string ReadPassphrase(bool confirm)
|
||||
{
|
||||
if (Console.IsInputRedirected) throw new InvalidOperationException("La phrase de passe doit être saisie au clavier.");
|
||||
while (true)
|
||||
{
|
||||
var first = ReadHidden("Phrase de passe de la clé : ");
|
||||
if (!confirm) return first;
|
||||
if (first.Length < AdminKeyFile.MinPassphraseLength)
|
||||
{
|
||||
Warn($"Au moins {AdminKeyFile.MinPassphraseLength} caractères, s'il vous plaît.");
|
||||
continue;
|
||||
}
|
||||
if (ReadHidden("Confirmez la phrase de passe : ") == first) return first;
|
||||
Warn("Les deux saisies sont différentes.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReadHidden(string prompt)
|
||||
{
|
||||
Console.Write(prompt);
|
||||
var text = new StringBuilder();
|
||||
while (true)
|
||||
{
|
||||
var key = Console.ReadKey(intercept: true);
|
||||
if (key.Key == ConsoleKey.Enter) break;
|
||||
if (key.Key == ConsoleKey.Backspace)
|
||||
{
|
||||
if (text.Length > 0) text.Length--;
|
||||
continue;
|
||||
}
|
||||
if (!char.IsControl(key.KeyChar)) text.Append(key.KeyChar);
|
||||
}
|
||||
Console.WriteLine();
|
||||
return text.ToString();
|
||||
}
|
||||
|
||||
private static int Usage()
|
||||
{
|
||||
Console.WriteLine($"""
|
||||
AdminKeyTool — clé d'administrateur et tests de la Launcher API
|
||||
|
||||
create [--out <fichier.key>] Crée une clé (défaut : {DefaultKeyPath})
|
||||
fingerprint [--key <fichier.key|.pub>] Affiche l'identifiant et l'empreinte
|
||||
login-test --api <url> [--key <fichier.key>] Vérifie la connexion
|
||||
enroll --api <url> --code <code> [--key …] [--label <poste>]
|
||||
Enregistre la clé avec un code d'invitation
|
||||
Gestion (propriétaire) :
|
||||
admins --api <url> [--key …] Administrateurs, clés, invitations
|
||||
invite --api <url> --name <nom> [--role owner|admin|editor]
|
||||
invite --api <url> --for <id admin> Invitation « nouvel appareil »
|
||||
approve --api <url> --admin <id> --key-id <clé> --fingerprint "AB12 CD34 EF56 7890 1A2B"
|
||||
role --api <url> --admin <id> --role <rôle>
|
||||
revoke-key --api <url> --admin <id> --key-id <clé>
|
||||
revoke-admin --api <url> --admin <id>
|
||||
audit --api <url> [--limit 30]
|
||||
|
||||
<url> : adresse de l'API, ex. https://launcher.lanisys.fr/api/
|
||||
""");
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static void Success(string message) => WriteColored(ConsoleColor.Green, message, Console.Out);
|
||||
private static void Warn(string message) => WriteColored(ConsoleColor.Yellow, message, Console.Out);
|
||||
private static void Error(string message) => WriteColored(ConsoleColor.Red, "Erreur : " + message, Console.Error);
|
||||
|
||||
private static void WriteColored(ConsoleColor color, string message, TextWriter writer)
|
||||
{
|
||||
Console.ForegroundColor = color;
|
||||
writer.WriteLine(message);
|
||||
Console.ResetColor();
|
||||
}
|
||||
|
||||
private sealed class Options
|
||||
{
|
||||
private readonly Dictionary<string, string> _values = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static Options Parse(string[] args)
|
||||
{
|
||||
var o = new Options();
|
||||
for (var i = 1; i < args.Length; i++)
|
||||
{
|
||||
if (!args[i].StartsWith("--", StringComparison.Ordinal)) throw new ArgumentException($"Argument inattendu : {args[i]}");
|
||||
if (i + 1 >= args.Length) throw new ArgumentException($"Valeur manquante pour {args[i]}");
|
||||
o._values[args[i][2..]] = args[++i];
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
public string? Get(string name) => _values.TryGetValue(name, out var value) ? value : null;
|
||||
|
||||
public string Require(string name) => Get(name) ?? throw new ArgumentException($"--{name} est obligatoire.");
|
||||
|
||||
public string KeyPath => Path.GetFullPath(Environment.ExpandEnvironmentVariables(Get("key") ?? Get("out") ?? DefaultKeyPath));
|
||||
|
||||
public Uri ApiUrl => Uri.TryCreate(Require("api"), UriKind.Absolute, out var uri)
|
||||
? uri
|
||||
: throw new ArgumentException("--api : adresse invalide (ex. https://launcher.lanisys.fr/api/).");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
@echo off
|
||||
REM ---------------------------------------------------------------------------
|
||||
REM Cle d'administrateur et tests de la Launcher API (tools\AdminKeyTool).
|
||||
REM Double-clic : cree votre cle (%USERPROFILE%\.launcher-keys\admin.key)
|
||||
REM admin-key.bat help : toutes les commandes (login-test, enroll, invite, approve...)
|
||||
REM Exemple : admin-key.bat login-test --api https://launcher.lanisys.fr/api/
|
||||
REM Guide : server\deploy\LISEZMOI.md
|
||||
REM ---------------------------------------------------------------------------
|
||||
setlocal
|
||||
set TOOL=%~dp0AdminKeyTool\AdminKeyTool.csproj
|
||||
if "%~1"=="" (
|
||||
dotnet run --project "%TOOL%" -c Release -- create
|
||||
pause
|
||||
exit /b
|
||||
)
|
||||
dotnet run --project "%TOOL%" -c Release -- %*
|
||||
+32
-37
@@ -1,50 +1,45 @@
|
||||
# Launcher 1.2.0
|
||||
# Launcher 1.2.1
|
||||
LAUNCHER - NOTES DE MISE À JOUR
|
||||
Mise à jour du 24 octobre 2026
|
||||
Mise à jour du 25 octobre 2026
|
||||
==================================================================
|
||||
|
||||
Cette version permet de lancer le jeu directement depuis son icône du Bureau, même quand le launcher est déjà ouvert, et répare les raccourcis après un déplacement du launcher.
|
||||
Cette version permet de garder le launcher discrètement près de l'horloge, pendant la partie ou à la place de le fermer.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
LANCEMENT DEPUIS LE BUREAU
|
||||
ZONE DE NOTIFICATION
|
||||
------------------------------------------------------------------
|
||||
- NOUVEAU : l'icône du jeu sur le Bureau lance directement le jeu.
|
||||
Le launcher s'ouvre, vérifie les mises à jour, puis démarre la
|
||||
partie :
|
||||
* vos réglages « Réduire le launcher pendant la partie » et
|
||||
« Fermer le launcher au lancement du jeu » sont respectés ;
|
||||
* si la mise à jour automatique du jeu est activée, la nouvelle
|
||||
version est installée avant de jouer ;
|
||||
* sans connexion Internet, la version installée est lancée.
|
||||
Si le jeu ne peut pas être lancé (mise à jour à installer,
|
||||
réparation nécessaire...), le launcher reste ouvert et vous
|
||||
explique pourquoi.
|
||||
- NOUVEAU : launcher déjà ouvert ? L'icône du Bureau n'affiche plus
|
||||
le message « Le launcher est déjà ouvert » : le launcher revient
|
||||
au premier plan sur le bon jeu et le lance.
|
||||
- Ouvert sans passer par l'icône du jeu, le launcher signale
|
||||
toujours qu'il est déjà ouvert.
|
||||
- Le jeu n'est plus lancé une seconde fois s'il est déjà en cours
|
||||
d'exécution, même s'il a été démarré sans le launcher.
|
||||
Le launcher peut maintenant se ranger dans la zone de notification
|
||||
de Windows (icônes cachées, près de l'horloge) :
|
||||
|
||||
- NOUVEAU : icône du launcher près de l'horloge.
|
||||
* clic gauche : le launcher réapparaît ;
|
||||
* clic droit : menu « Ouvrir » / « Quitter ».
|
||||
- NOUVEAU : Paramètres du launcher (roue crantée) › Lancement du
|
||||
jeu : sous « Réduire le launcher pendant la partie », l'option
|
||||
« Dans la zone de notification » le range près de l'horloge
|
||||
plutôt que dans la barre des tâches. Il réapparaît à la fermeture
|
||||
du jeu.
|
||||
- NOUVEAU : bloc « Fermeture du launcher » : « La croix réduit le
|
||||
launcher dans la zone de notification ». Le launcher reste actif :
|
||||
les téléchargements et les mises à jour continuent. Pour le
|
||||
quitter : clic droit sur son icône › Quitter.
|
||||
|
||||
Ces deux options sont désactivées par défaut : rien ne change si
|
||||
vous ne les activez pas.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
LAUNCHER DÉPLACÉ ET RÉPARATION
|
||||
AUTRES AMÉLIORATIONS
|
||||
------------------------------------------------------------------
|
||||
Vous avez déplacé ou renommé GameLauncher.exe ? Les raccourcis
|
||||
suivent :
|
||||
|
||||
- Au démarrage, les raccourcis du Bureau qui pointaient vers
|
||||
l'ancien emplacement sont corrigés automatiquement, même si vous
|
||||
les avez renommés.
|
||||
- NOUVEAU : le bouton « Réparer » vérifie aussi les raccourcis du
|
||||
Bureau et le bouton « Désinstaller » de Windows, et les fait
|
||||
pointer vers l'emplacement actuel du launcher. Cette vérification
|
||||
fonctionne même sans connexion Internet.
|
||||
* si un raccourci ouvre une autre copie du launcher, encore
|
||||
présente, le launcher vous demande avant de le modifier ;
|
||||
* l'icône que vous avez personnalisée est conservée.
|
||||
- « Quitter » pendant un téléchargement : le launcher réapparaît et
|
||||
vous demande confirmation.
|
||||
- L'icône du jeu sur le Bureau fonctionne aussi quand le launcher
|
||||
est caché près de l'horloge : il réapparaît et lance le jeu.
|
||||
- Le message « Le launcher est déjà ouvert » indique où le
|
||||
retrouver s'il est caché près de l'horloge.
|
||||
- À l'arrêt de Windows ou à la fermeture de session, le launcher se
|
||||
ferme normalement, sans rien demander.
|
||||
|
||||
|
||||
La sécurité ne change pas : la signature numérique et l'empreinte SHA-256 de chaque nouvelle version sont vérifiées avant toute installation. La communication entre l'icône du Bureau et le launcher déjà ouvert reste limitée à votre session Windows.
|
||||
La sécurité ne change pas : la signature numérique et l'empreinte SHA-256 de chaque nouvelle version sont vérifiées avant toute installation.
|
||||
+81
-57
@@ -1,78 +1,102 @@
|
||||
# Version 1.5
|
||||
# Version 1.6
|
||||
EXOVIVAL ZOMBIE REBORN - NOTES DE MISE A JOUR
|
||||
Niveau 1 « L'école » - Mise à jour du 24 octobre 2026
|
||||
Niveau 1 « L'école » - Mise à jour du 25 octobre 2026
|
||||
==================================================================
|
||||
|
||||
Cette mise à jour branche une grande partie de l'école sur le groupe électrogène et son armoire de distribution, rend les objets bien plus faciles à ramasser et corrige plusieurs problèmes de lumière, de traduction et de fin de partie.
|
||||
Merci aux bêta-testeurs ! Cette version supprime un blocage possible au gymnase, rend le crochetage plus clair et ajuste l'équilibre des lumières et de l'exploration.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
COURANT ET GROUPE ELECTROGENE
|
||||
PROGRESSION : PROBLÈME BLOQUANT CORRIGÉ
|
||||
------------------------------------------------------------------
|
||||
- NOUVEAU : chaque circuit de l'armoire de distribution compte.
|
||||
* levier ÉCOLE baissé : toute l'école perd le courant (néons,
|
||||
télé, magnétoscope, vidéosurveillance, gymnase...), l'extérieur
|
||||
reste éclairé ;
|
||||
* levier EXTÉRIEUR baissé : seul le projecteur de chantier
|
||||
s'éteint, l'école reste éclairée.
|
||||
Relevez le levier, groupe en marche, et le courant revient.
|
||||
- Les leviers de l'armoire ne se bloquent plus après le premier
|
||||
démarrage du groupe : on peut encore provoquer une surcharge. Le
|
||||
voyant de l'armoire s'éteint dès que le réglage n'est plus bon.
|
||||
- Le lecteur de carte de la porte du toit ne fonctionne plus sans
|
||||
courant. Une porte déjà ouverte avec la carte reste ouverte après
|
||||
une coupure.
|
||||
- L'étiquette du groupe électrogène a changé de place : elle est
|
||||
maintenant rangée dans le local technique.
|
||||
- Les messages de coupure et de retour du courant ne donnent plus la
|
||||
solution : à vous de trouver d'où vient la panne.
|
||||
- Coffre du gymnase : casser les trois épingles à cheveux ne bloque
|
||||
plus la partie. La dernière épingle ne casse jamais : vous pouvez
|
||||
réessayer autant que vous voulez pour récupérer le fusible n°2.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
LUMIERE
|
||||
CROCHETAGE
|
||||
------------------------------------------------------------------
|
||||
- NOUVEAU : néons au plafond dans la remise du concierge, le local
|
||||
technique et la grande salle du couloir ouest. Ils s'allument avec
|
||||
le courant et s'éteignent avec lui, comme les autres.
|
||||
- Les lampes cassées ou tombées au sol (gymnase, couloir vers le
|
||||
gymnase, bibliothèque) restent éteintes quand le courant revient.
|
||||
- Lanterne : la forme de sa cage n'est plus projetée tout autour de
|
||||
vous ; elle produit maintenant de vraies ombres.
|
||||
- Lanterne : un plein dure 3 minutes au lieu de 60 secondes.
|
||||
* elle ne consomme que lorsqu'elle est en main ;
|
||||
* elle se recharge avec du pétrole (Tab, pétrole, « Combiner »
|
||||
avec la lanterne), pas avec les jerricans.
|
||||
- NOUVEAU : les commandes s'affichent pendant le crochetage.
|
||||
* souris : placer l'épingle ;
|
||||
* Espace (maintenir) : tourner la serrure ;
|
||||
* Q : retour.
|
||||
- NOUVEAU : le nombre d'épingles restantes s'affiche.
|
||||
- NOUVEAU : en ramassant les épingles à cheveux, une aide explique
|
||||
comment crocheter une serrure.
|
||||
- Le crochetage est plus tolérant : la serrure réagit plus tôt quand
|
||||
l'épingle approche du bon angle, et une épingle résiste plus
|
||||
longtemps avant de casser.
|
||||
|
||||
Astuce : plus la serrure tourne loin, plus l'épingle est proche du bon angle. Si elle bloque, relâchez et décalez l'épingle : forcer l'use jusqu'à la casser.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
OBJETS ET FOUILLE
|
||||
OBJETS ET EXPLORATION
|
||||
------------------------------------------------------------------
|
||||
- Il n'est plus nécessaire d'être tout près ou accroupi pour prendre
|
||||
un objet posé au sol ou sur un meuble bas : la portée passe de
|
||||
1,5 à 2,5 mètres. Les petits objets (clés, carte, cassettes,
|
||||
munitions, fusibles, bille) sont aussi plus faciles à viser.
|
||||
- Caisses en bois : l'objet qui en sort n'est plus caché par les
|
||||
débris, il se voit et se ramasse tout de suite. La première fois,
|
||||
un message vous prévient qu'un objet est tombé.
|
||||
- Un bidon de pétrole remplace une pile dans un casier du couloir
|
||||
ouest : le niveau compte maintenant 3 bidons pour la lanterne.
|
||||
- La vraie cassette « CM1 - Mme Roussel (12/10) » a changé de place :
|
||||
elle est maintenant sur une étagère de la bibliothèque.
|
||||
- La pile cachée dans les toilettes est n'est plus enfoncée dans la
|
||||
cuvette.
|
||||
- Fusible n°1 : il n'est plus posé à côté du registre de la salle des
|
||||
professeurs. Cherchez-le ailleurs dans l'école : le registre
|
||||
indique où il a été rangé.
|
||||
- NOUVEAU : un deuxième couteau de poche se trouve dans l'école.
|
||||
* si vous avez raté celui du car, vous pouvez quand même casser
|
||||
les caisses et la vitrine de la hache ;
|
||||
* on ne porte qu'un seul couteau : le second reste en place, avec
|
||||
un message.
|
||||
- La bougie de la classe de CM1 est éteinte tant qu'on ne l'a pas
|
||||
prise en main.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
CORRECTIONS
|
||||
LUMIÈRES : DURÉE RÉDUITE
|
||||
------------------------------------------------------------------
|
||||
- La fiche du concierge, dans la remise, affiche bien son titre et
|
||||
son texte. Avant, elle prenait le nom du dernier objet regardé et
|
||||
restait vide à la lecture.
|
||||
- Lampe torche en main : l'aide « UV Flashlight » est traduite
|
||||
(« Lampe UV »).
|
||||
- Fin du niveau : au retour au menu principal, la souris réapparaît
|
||||
et les menus fonctionnent de nouveau. Avant, il fallait fermer le
|
||||
jeu de force.
|
||||
- Lampe torche : une pile dure maintenant 4 minutes (au lieu de 12).
|
||||
- Lanterne : un bidon de pétrole dure maintenant 2 minutes 30 (au
|
||||
lieu de 3 minutes).
|
||||
|
||||
Gardez un œil sur vos piles !
|
||||
|
||||
|
||||
Vos sauvegardes de la version 1.4.3 restent compatibles.
|
||||
------------------------------------------------------------------
|
||||
INVENTAIRE
|
||||
------------------------------------------------------------------
|
||||
- NOUVEAU : inventaire plein. La première fois qu'un objet ne peut pas
|
||||
être ramassé faute de place, une aide vous l'indique :
|
||||
* faites de la place (Tab : lâcher ou jeter un objet) ;
|
||||
* ou réarrangez vos objets ;
|
||||
* ou déposez-en dans une caisse de rangement de l'école.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
CAISSES ET OBJETS TENUS
|
||||
------------------------------------------------------------------
|
||||
- Tourner une caisse (R maintenu + souris) est beaucoup plus réactif :
|
||||
la rotation suit maintenant le mouvement de la souris.
|
||||
- La molette rapproche ou éloigne l'objet tenu bien plus vite (avant,
|
||||
quelques millimètres seulement par cran).
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
SAUVEGARDES
|
||||
------------------------------------------------------------------
|
||||
- Charger une partie : les emplacements sont numérotés dans l'ordre
|
||||
chronologique. La plus ancienne sauvegarde porte le n°0 et la plus
|
||||
récente le numéro le plus élevé (avant, c'était l'inverse).
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
TEXTES ET INTERFACE
|
||||
------------------------------------------------------------------
|
||||
- Les messages d'aide de plusieurs lignes ne débordent plus du cadre
|
||||
noir en haut de l'écran.
|
||||
- Le registre d'appel est mis à jour (nouvel emplacement du
|
||||
fusible n°1).
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
CONNU, EN COURS
|
||||
------------------------------------------------------------------
|
||||
- La visée du pistolet fait un simple zoom au lieu de viser par le
|
||||
viseur. Une amélioration est prévue.
|
||||
|
||||
|
||||
Vos sauvegardes restent compatibles. Une partie commencée avant cette version garde l'ancien emplacement du fusible n°1 (s'il n'a pas encore été ramassé) et la charge des piles déjà en place : pour profiter de tous les changements, commencez une nouvelle partie.
|
||||
@@ -0,0 +1,50 @@
|
||||
@echo off
|
||||
REM ---------------------------------------------------------------------------
|
||||
REM Publie la LAUNCHER API pour le VPS (Linux x64, autonome : aucun .NET a
|
||||
REM installer sur le serveur) et prepare les fichiers a envoyer :
|
||||
REM Publish\Server\launcher-api.tar.gz serveur + service systemd
|
||||
REM Publish\Server\installer-api.sh script d'installation / mise a jour
|
||||
REM Guide complet : server\deploy\LISEZMOI.md
|
||||
REM ---------------------------------------------------------------------------
|
||||
setlocal
|
||||
set ROOT=%~dp0..
|
||||
set OUT=%ROOT%\Publish\Server
|
||||
set STAGE=%OUT%\launcher-api
|
||||
|
||||
if exist "%STAGE%" rmdir /s /q "%STAGE%"
|
||||
if exist "%OUT%\launcher-api.tar.gz" del /q "%OUT%\launcher-api.tar.gz"
|
||||
set HASH=
|
||||
|
||||
echo === 1/2 Compilation (linux-x64) ===
|
||||
dotnet publish "%ROOT%\server\GameLauncher.Server\GameLauncher.Server.csproj" -c Release -r linux-x64 --self-contained true -p:PublishSingleFile=true -p:DebugType=None -o "%STAGE%"
|
||||
if errorlevel 1 goto :error
|
||||
if exist "%STAGE%\appsettings.Development.json" del /q "%STAGE%\appsettings.Development.json"
|
||||
|
||||
echo.
|
||||
echo === 2/2 Archive ===
|
||||
mkdir "%STAGE%\deploy"
|
||||
copy /Y "%ROOT%\server\deploy\launcher-api.service" "%STAGE%\deploy\" >nul
|
||||
copy /Y "%ROOT%\server\deploy\installer-api.sh" "%OUT%\" >nul
|
||||
tar -czf "%OUT%\launcher-api.tar.gz" -C "%STAGE%" .
|
||||
if errorlevel 1 goto :error
|
||||
|
||||
REM Empreinte SHA-256 : verifiee par installer-api.sh (archive alteree = refusee)
|
||||
for /f %%h in ('powershell -NoProfile -Command "(Get-FileHash -Algorithm SHA256 -LiteralPath '%OUT%\launcher-api.tar.gz').Hash.ToLower()"') do set HASH=%%h
|
||||
if "%HASH%"=="" goto :error
|
||||
> "%OUT%\launcher-api.tar.gz.sha256" echo %HASH%
|
||||
|
||||
echo.
|
||||
echo Pret. Envoi sur le VPS (compte admin) :
|
||||
echo scp "%OUT%\launcher-api.tar.gz" "%OUT%\installer-api.sh" admin@launcher.lanisys.fr:/tmp/
|
||||
echo Puis, sur le VPS :
|
||||
echo sudo sh /tmp/installer-api.sh /tmp/launcher-api.tar.gz %HASH%
|
||||
echo (empreinte aussi enregistree dans launcher-api.tar.gz.sha256)
|
||||
explorer "%OUT%"
|
||||
pause
|
||||
exit /b 0
|
||||
|
||||
:error
|
||||
echo.
|
||||
echo La publication a echoue.
|
||||
pause
|
||||
exit /b 1
|
||||
Reference in new issue
Block a user