Compare commits

...
29 Commits
Author SHA1 Message Date
nicolas 920d215fdd docs(admin): corrections du document d'architecture
- § 6.2 : ligne de vérification en double retirée, ordre aligné sur le code
- § 8 : codes d'invitation de 160 bits (et non 128)
2026-10-25 21:43:17 +01:00
nicolas 22468122e0 Vesion 1.6 du jeu 2026-10-25 21:02:25 +01:00
nicolas c2c67a63f2 docs(readme): nouveaux projets et section « 8. Administration (Launcher API) » 2026-10-25 21:02:03 +01:00
nicolas d5aa875178 build: ajout des nouveaux projets à la solution
Contracts, ApiClient, Server, AdminKeyTool, Tests ; dossiers de solution
server, deploy et tests ; architecture et scripts en éléments de solution.
2026-10-25 21:01:55 +01:00
nicolas b9fea61e64 test: 84 tests du protocole et de la Launcher API
Unitaires (clés, empreintes, messages signés, invitations, rôles) et de bout
en bout contre un serveur démarré en mémoire : connexion, rejeu, expiration,
requêtes falsifiées, invitations, approbation, droits, révocation, limites,
HTTPS, audit, CLI.
2026-10-25 21:01:39 +01:00
nicolas 14a76927e1 feat(tools): AdminKeyTool, clé d'administrateur et gestion en ligne de commande
- create, fingerprint, login-test, enroll
- admins, invite, approve, role, revoke-key, revoke-admin, audit
- admin-key.bat : double-clic = création de la clé
2026-10-25 21:01:07 +01:00
nicolas 5e68c4b16a feat(deploy): installation de la Launcher API sur le VPS
- Service systemd durci (compte deploy, sans connexion sortante)
- Bloc nginx /api/ avec limite de requêtes
- installer-api.sh : empreinte SHA-256 obligatoire, retour arrière automatique
- publish-server.bat : publication linux-x64 autonome + archive + empreinte
- Guide pas à pas : installation, premier propriétaire, récupération
2026-10-25 21:00:37 +01:00
nicolas e5b9ebf3ff feat(server): Launcher API, serveur d'administration (phase 1)
- Architecture en modules (System, Auth, Admins, Audit)
- Connexion par défis sans état scellés par HMAC, à usage unique
- Sessions en mémoire (20 min d'inactivité, 8 h max)
- Toute modification signée par la clé de l'admin (anti-rejeu, horloge ±2 min)
- Invitations, enrôlement avec preuve de possession, approbation par empreinte
- Nouvel appareil approuvé uniquement par le titulaire du compte
- Rôles, révocation, au moins un propriétaire conservé, droits revérifiés
  au moment d'écrire
- Audit mensuel lu à rebours, tentatives anonymes regroupées
- Limites par IP (/64 en IPv6), HTTPS exigé derrière nginx, en-têtes de sécurité
- admins.json atomique, verrou partagé avec la CLI, fichiers en 600
- CLI : add-owner, add-key, list, revoke-admin, revoke-key, server-id
2026-10-25 21:00:05 +01:00
nicolas fecf95ae3e feat(api-client): client de la Launcher API
- Connexion par défi signé, session en mémoire, requêtes signées
- Fichier de clé chiffré (AES-256, PBKDF2 600 000 itérations)
- HTTPS obligatoire, aucune redirection, jeton limité à l'adresse de l'API
- Heure calée sur le serveur (écart limité à 1 h), réponses bornées
2026-10-25 20:58:30 +01:00
nicolas 3a788c18d2 feat(contracts): protocole partagé launcher ⇄ Launcher API
- Routes, objets échangés, codes d'erreur, options JSON strictes
- Rôles (propriétaire, administrateur, éditeur) et permissions module.action
- Clés ECDSA P-256 : contrôle, signature, vérification, empreinte 80 bits
  séparée de l'identifiant de clé
- Messages signés séparés par usage (connexion, enrôlement, requête)
- Codes d'invitation 160 bits, nettoyage des textes saisis
2026-10-25 20:58:02 +01:00
nicolas dbe5d9051d docs: architecture du module d'administration et plan en 7 phases
Modèle de sécurité, authentification par clé publique/privée, rôles et
permissions, ajout d'administrateurs, séparation des informations fixes et
modifiables des jeux, modules launcher/serveur, phases du chantier.
2026-10-25 20:56:41 +01:00
nicolas ae2a5a4ab7 chore(git): fins de ligne LF pour les fichiers Linux, ignorer les clés privées
- .gitattributes : *.sh, *.service et server/deploy/*.conf en LF
- .gitignore : *.key (clés privées) et data-dev/ (données locales de l'API)
- preparer-vps.sh converti en LF (fins de ligne Windows incompatibles avec sh)
2026-10-25 20:56:23 +01:00
nicolas_gitea e8e08f792e Merge pull request 'Désinstallation : mêmes sécurités pour les dossiers Roaming et LocalLow' (#87) from dev/1.3.0 into master
Reviewed-on: #87
Reviewed-by: Nicolas Deville <nicolas.deville@lanisys.fr>
2026-10-25 12:01:54 +01:00
nicolas_gitea c41bcda853 Merge branch 'master' into dev/1.3.0 2026-10-25 12:01:34 +01:00
nicolas e41c3a977e Désinstallation : mêmes sécurités pour les dossiers Roaming et LocalLow
- Sauvegardes (Roaming) et journaux (LocalLow) supprimés comme le
  dossier du jeu : fichier par fichier, fichiers du launcher conservés
  s'il s'y trouve, fichiers bloqués signalés sans interrompre.
- Règle commune : FileUtil.DeleteDirectoryContents (dossier du jeu,
  sauvegardes, journaux).
- Confirmation : « Le launcher se trouve dans le dossier des données du
  jeu : il sera conservé. » ; bandeau final : emplacement du launcher
  conservé, total des fichiers bloqués, dossiers refusés.
- README : section Désinstallation.
2026-10-25 11:59:43 +01:00
nicolas f5e6c99aca Merge pull request 'Dev/1.3.0' (#86) from dev/1.3.0 into master
Reviewed-on: #86
2026-10-25 11:51:09 +01:00
nicolas 35664045ce Désinstallation : « Supprimer aussi… » supprime les données du jeu
- L'option supprime désormais, en plus du dossier d'installation :
  * sauvegardes et réglages (%APPDATA%\<Société>\<Produit>) ;
  * journaux de Unity (AppData\LocalLow\<Société>\<Produit>) ;
  * réglages d'affichage de Unity (HKCU\Software\<Société>\<Produit>).
- Option proposée aussi quand les fichiers du jeu ont disparu.
- Garde-fous : chemin Société\Produit complet uniquement, jamais le
  dossier ni la clé du launcher ni le dossier d'installation ; dossier
  et clé de la société retirés seulement s'ils sont vides.
- Messages : emplacement des sauvegardes conservées, éléments non
  supprimés signalés.
- README : section Désinstallation.

Désinstallation : launcher conservé et fichiers bloqués tolérés

- Launcher dans le dossier du jeu (ou un sous-dossier) : signalé dans
  la confirmation et épargné, même avec « Supprimer aussi… » (exe,
  launcher.json, fichiers de mise à jour, DLL en développement) ;
  bandeau final indiquant où il a été conservé.
- Suppression fichier par fichier : un fichier bloqué n'interrompt
  plus la désinstallation ; fichiers non supprimés signalés (bandeau
  + journal), registre, raccourci et données du jeu traités ensuite.
- Nouveau LauncherSelfFiles (Core/Utils).
- README : section Désinstallation.
2026-10-25 11:44:03 +01:00
nicolas ad5d92ee8b Notes de version du jeu 1.5.1 : sauvegardes et réglages dans Roaming 2026-10-25 11:13:26 +01:00
nicolas 047ec0d1db Données du launcher : dossier et registre « GameLauncher »
- %APPDATA%\<Publisher>\GameLauncher (au lieu de <GameId>Launcher) :
  paramètres, mise à jour du launcher, journaux ; paramètres de chaque
  jeu dans Games\<GameId>\settings.json.
- Registre : HKCU\Software\<Publisher>\GameLauncher\Games\<GameId>
  (au lieu de HKCU\Software\<Publisher>\<GameId>).
- Déplacement automatique au démarrage depuis les emplacements de la
  1.2.1 (dossier renommé, clé copiée et vérifiée puis supprimée), sauf
  si un autre launcher est ouvert ou au redémarrage après mise à jour du
  launcher ; ancien emplacement utilisé en attendant.
- Nouveau réglage facultatif « dataFolder » (launcher.json), distinct de
  launcherId (identifiant de produit pour les mises à jour, inchangé).
- README : registre et fichiers.

Registre : LauncherPath unique à la racine de GameLauncher

- LauncherPath écrit une seule fois dans
  HKCU\Software\<Publisher>\GameLauncher (et non plus dans la clé de
  chaque jeu) ; anciennes valeurs par jeu supprimées ; retiré à la
  désinstallation du dernier jeu.
- Seul le launcher réellement ouvert (verrou d'instance unique) écrit
  dans le registre : un second launcher (« Le launcher est déjà
  ouvert » ou raccourci transmis) n'écrase jamais LauncherPath ni le
  bouton « Désinstaller » de Windows.
- README : schéma du registre.
2026-10-25 11:08:43 +01:00
nicolas 6243bd2574 Données du jeu : dossiers des journaux (LocalLow) et des données (Roaming)
- Paramètres du jeu › Informations :
  * « Ouvrir le dossier des journaux » ouvre le dossier LocalLow du jeu
    (Player.log de Unity) ;
  * nouveau bouton « Ouvrir le dossier des données » : sauvegardes et
    réglages du jeu dans %APPDATA%\LanisysGames\Exovival Zombie Reborn.
  Info-bulles avec le chemin ; boutons grisés si le dossier n'existe pas.
- launcher.json : nouveau réglage facultatif « gameDataFolder »
  (Société\Produit de Unity, par défaut publisher\gameName), validé.
- README : section « Données du jeu (Unity) ».
2026-10-25 10:38:10 +01:00
nicolas 7ee4d84dd2 Raccourci du Bureau : bouton dans les paramètres du jeu + icône du jeu
- Paramètres du jeu (menu « … ») : nouveau bloc « Raccourci » avec
  « Créer le raccourci » / « Mettre à jour » (action immédiate,
  résultat affiché, grisé si le jeu n'est pas installé).
- Icône du raccourci : celle de l'exécutable du jeu (emplacement lu
  dans le registre), sinon celle du launcher. Les raccourcis existants
  prennent l'icône du jeu au démarrage ; elle suit le jeu s'il est
  déplacé. Une icône personnalisée par le joueur est conservée.
- Explorateur notifié après chaque modification (icône à jour tout de
  suite).
- README : création du raccourci et icône.
2026-10-25 10:01:05 +01:00
nicolas f4ebb680ef Merge pull request 'Note de version 1.2.1' (#85) from dev/1.2.1 into master
Reviewed-on: #85
2026-10-25 09:46:03 +01:00
nicolas 756a76582d Merge branch 'master' into dev/1.2.1 2026-10-25 09:45:34 +01:00
nicolas b5b922019c Note de version 1.2.1 2026-10-25 09:43:41 +01:00
nicolas 7ee8da3e67 Merge pull request 'Dev/1.2.1' (#84) from dev/1.2.1 into master
Reviewed-on: #84
2026-10-25 09:40:20 +01:00
nicolas 0e8c918922 README : zone de notification 2026-10-25 09:32:12 +01:00
nicolas e2f2ae5817 Zone de notification : options « pendant la partie » et « croix »
- Paramètres du launcher :
  * Lancement du jeu › Réduire › « Dans la zone de notification » ;
  * nouveau bloc « Fermeture du launcher » : la croix (et Alt+F4)
    réduit dans la zone de notification au lieu de quitter.
  Options désactivées par défaut.
- Quitter depuis l'icône : confirmation si une opération est en cours.
- Raccourci du Bureau : réaffiche le launcher s'il est masqué.
- Fermeture normale à l'arrêt de Windows ; message « déjà ouvert »
  complété.
2026-10-25 09:32:00 +01:00
nicolas 9dda708c94 Zone de notification : icône du launcher et menu Ouvrir / Quitter
- Platform/TrayIcon : icône près de l'horloge (API Shell_NotifyIcon,
  sans Windows Forms), affichée quand la fenêtre est masquée. Clic
  gauche : ouvrir ; clic droit : menu. Recréée si l'Explorateur
  redémarre.
- Views/TrayMenuWindow : menu Ouvrir / Quitter au style du launcher,
  fermé en cliquant ailleurs, avec Échap ou après un choix.
- IWindowService.HideToTray() ; Restore() réaffiche une fenêtre masquée.
- Icônes WindowRestore et ExitToApp.
2026-10-25 09:31:14 +01:00
fred a17f0efcd7 Merge pull request 'Dev/1.2' (#82) from dev/1.2 into master
Reviewed-on: #82
Reviewed-by: Deville <fred@lanisys.fr>
2026-10-24 20:19:40 +02:00
97 changed files with 8461 additions and 229 deletions

No files matched your search

+7
View File
@@ -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
+5
View File
@@ -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/
+71
View File
@@ -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}
+132 -15
View File
@@ -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/` |
+345
View File
@@ -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 |
+257
View File
@@ -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));
}
}
+21
View File
@@ -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
}
}
}
+176
View File
@@ -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
```
+93
View File
@@ -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."
+72
View File
@@ -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
+34
View File
@@ -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;
}
}
}
+150
View File
@@ -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() { }
}
+24
View File
@@ -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
};
}
+39
View File
@@ -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";
}
+40
View File
@@ -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;
}
+33
View File
@@ -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);
}
+155 -7
View File
@@ -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%\&lt;Publisher&gt;\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%\&lt;Publisher&gt;</summary>
public string PublisherDirectory =>
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), Publisher);
/// <summary>
/// Dossier des données du launcher : %AppData%\&lt;Publisher&gt;\GameLauncher (paramètres du
/// launcher, mise à jour du launcher, journaux, dernier jeu affiché ; paramètres de chaque jeu
/// dans Games\&lt;GameId&gt;). 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%\&lt;Publisher&gt;\&lt;LauncherId&gt;
/// (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 : &lt;DataDirectory&gt;\Games\&lt;GameId&gt;\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%\&lt;Publisher&gt;\&lt;LauncherId&gt; (ex. EZRLauncher) → %AppData%\&lt;Publisher&gt;\GameLauncher
/// (renommage du dossier, instantané) ;
/// • paramètres de chaque jeu (settings.json à la racine de l'ancien dossier, ou dans
/// %AppData%\&lt;Publisher&gt;\&lt;GameId&gt;Launcher) → GameLauncher\Games\&lt;GameId&gt;\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%\&lt;Publisher&gt;\&lt;LauncherId&gt;\launcher-settings.json.
/// %AppData%\&lt;Publisher&gt;\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%\&lt;Publisher&gt;\&lt;LauncherId&gt;\self-update.json
/// %AppData%\&lt;Publisher&gt;\GameLauncher\self-update.json (voir LauncherCatalog.DataDirectory)
/// </summary>
public sealed class SelfUpdateSettings
{
+39 -17
View File
@@ -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)
+69
View File
@@ -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;
}
}
}
+88 -11
View File
@@ -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>
+1 -1
View File
@@ -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>
+261
View File
@@ -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);
}
+25 -1
View File
@@ -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);
+19 -3
View File
@@ -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\&lt;Société&gt;\&lt;Produit&gt;). 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();
+189 -18
View File
@@ -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\&lt;Publisher&gt;\&lt;GameId&gt;
/// • HKCU\Software\&lt;Publisher&gt;\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\&lt;Publisher&gt;\GameLauncher\Games\&lt;GameId&gt;
/// InstallPath, Version, Executable, InstallDate
/// (launcher 1.2.1 et antérieurs : HKCU\Software\&lt;Publisher&gt;\&lt;GameId&gt;, 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\&lt;Publisher&gt;.&lt;GameId&gt;
/// entrée visible dans "Paramètres › Applications installées" ; le bouton
/// "Désinstaller" de Windows relance le launcher avec l'argument --uninstall &lt;GameId&gt;.
@@ -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\&lt;Publisher&gt;\&lt;GameId&gt; vers
/// HKCU\Software\&lt;Publisher&gt;\GameLauncher\Games\&lt;GameId&gt; : 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);
}
}
+140 -16
View File
@@ -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 &lt;GameId&gt; » (guillemets tolérés).</summary>
+2
View File
@@ -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()
{
+181 -18
View File
@@ -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;
+1 -1
View File
@@ -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>
+17 -2
View File
@@ -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);
}
}
+58 -1
View File
@@ -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);
}
+6 -1
View File
@@ -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("admin‮gnp.exe")] // inversion bidirectionnelle
[InlineData("zéro​largeur")]
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);
}
}
+24
View File
@@ -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>
+292
View File
@@ -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/).");
}
}
+16
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+50
View File
@@ -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