Compare commits

...
74 Commits
Author SHA1 Message Date
nicolas 001ab82fda VErsion 1.7 du jeu 2026-10-31 17:51:29 +01:00
nicolas 67f4c02efc Documentation : phase 4 (notes de version) 2026-10-31 11:55:23 +01:00
nicolas 8808fe2615 Déploiement : notes de version modifiables sur le VPS (complément systemd, guide) 2026-10-31 11:55:09 +01:00
nicolas bd5b9d46ca Launcher : page « Notes de version » (vues) et enregistrement du module 2026-10-31 11:54:45 +01:00
nicolas 688b726e24 Administration : module « Notes de version » (éditeur, aperçu, enregistrement) 2026-10-31 11:54:21 +01:00
nicolas 929b27dbeb Client API : lecture et modification des notes de version 2026-10-31 11:53:48 +01:00
nicolas 090d8c64ec Serveur : module Changelog (notes d'une version dans le manifest publié, empreinte, audit) 2026-10-31 11:53:28 +01:00
nicolas 93f3ba630c Notes de version : modèle partagé dans Contracts, historique en ligne pour ManifestBuilder (--remote-manifest) 2026-10-31 11:52:25 +01:00
nicolas 89775bd73b Documentation : phase 3 (catalogue des jeux) 2026-10-31 10:34:13 +01:00
nicolas 944e47dea7 Déploiement : catalogue sur le VPS (complément systemd, nginx, guide) 2026-10-31 10:33:56 +01:00
nicolas c44c9fe3ef Launcher : catalogue au démarrage, fiche du jeu (description, liens), vues de la page « Jeux » 2026-10-31 10:33:25 +01:00
nicolas 6d44b5680a Administration : module « Catalogue des jeux » et page « Jeux » (ViewModels) 2026-10-31 10:32:30 +01:00
nicolas a6b2f2d6bb Core : catalogue côté joueur (téléchargement borné, cache, images, application) 2026-10-31 10:31:31 +01:00
nicolas e888b4037b Client API : lecture et publication du catalogue 2026-10-31 10:30:31 +01:00
nicolas 417dd78df5 Serveur : module Catalog (GET/PUT v1/catalog, fichier public, révisions, audit) 2026-10-31 10:30:12 +01:00
nicolas c8836a9baf Contrats : format et règles du catalogue des jeux (catalog.json) 2026-10-31 10:27:51 +01:00
nicolas e887a91d9b Docs : suppression et blocage TPM 2026-10-30 21:43:02 +01:00
nicolas c1ad053861 Tests : suppression et blocage TPM 2026-10-30 21:42:51 +01:00
nicolas 5195398943 Admin : aide en cas de blocage de la puce (essais restants, décompte) 2026-10-30 21:42:43 +01:00
nicolas fd90c436e7 Admin : suppression des clés TPM 2026-10-30 21:42:20 +01:00
nicolas ca194c2bf8 Docs : phase 2b 2026-10-30 21:08:32 +01:00
nicolas 4b7b7bb83c Tests : clés TPM 2026-10-30 21:08:21 +01:00
nicolas 270e311fb4 Launcher : puce TPM (Windows) et écrans 2026-10-30 21:08:07 +01:00
nicolas 80d0eb93c5 Admin : connexion et invitation avec une clé TPM 2026-10-30 21:07:30 +01:00
nicolas 6d4219228e Admin : contrat et fichier de référence des clés TPM 2026-10-30 21:06:26 +01:00
nicolas 20991ddc61 Module Administration 2026-10-30 19:57:44 +01:00
nicolas fa01962d03 Ils ont été déplacés et ne sont plus utiles 2026-10-30 19:57:12 +01:00
nicolas 53d87f3cc6 Docs : phase 2 2026-10-30 19:56:43 +01:00
nicolas dc18e01540 Tests : administration 2026-10-30 19:55:59 +01:00
nicolas 723da51c65 Launcher : menu d’administration (WPF) 2026-10-30 19:55:30 +01:00
nicolas cf3e3f5b57 Module Administration 2026-10-30 19:54:10 +01:00
nicolas 7c7e12f694 Configuration : apiUrl 2026-10-30 19:53:41 +01:00
nicolas 950d6acc4d Client API : clé et session 2026-10-30 19:53:01 +01:00
nicolas 39dd8300e4 Modules : socle commun 2026-10-30 19:52:44 +01:00
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
nicolas 5472522429 Notes de version du launcher 1.2.0
- Lancement du jeu depuis l'icône du Bureau, launcher fermé ou déjà
  ouvert (réglages Réduire / Fermer respectés).
- Réparation des raccourcis après un déplacement du launcher.
2026-10-24 17:31:49 +02:00
nicolas a443433a58 Version 1.2.0 2026-10-24 17:21:31 +02:00
nicolas 5a1985a2b5 Raccourci du Bureau : lance le jeu même si le launcher était fermé
- Lancé avec --game, le launcher vérifie les mises à jour du jeu
  (et les installe si la mise à jour automatique est activée), puis
  lance le jeu s'il est prêt, en respectant les réglages « Réduire le
  launcher pendant la partie » et « Fermer le launcher au lancement
  du jeu ». Sinon, le bandeau explique pourquoi.
- Nouvel argument interne --launch : après une mise à jour du launcher
  faite au démarrage depuis un raccourci, la nouvelle version lance le
  jeu. Après une mise à jour demandée depuis les paramètres, le jeu
  n'est pas lancé.
- README : raccourcis du Bureau et options --game / --updated.
2026-10-24 17:19:57 +02:00
nicolas 95238a2e84 README : raccourcis du Bureau et déplacement du launcher
- Nouvelle section « Raccourcis du Bureau et déplacement du launcher ».
- Réparation : mention du contrôle des raccourcis.
- Option --game : comportement si le launcher est déjà ouvert.
- Structure : ajout d'InstanceChannel.
2026-10-24 17:05:38 +02:00
nicolas 8797f9e3d3 Raccourci ouvert avec le launcher déjà ouvert : le jeu est lancé
- Lancé avec --game alors qu'une instance est déjà ouverte, le launcher
  lui transmet la demande puis se ferme. L'instance ouverte revient au
  premier plan sur ce jeu et le lance s'il est prêt et pas déjà lancé.
- Sans --game (ou sans réponse sous 2 s) : message « Le launcher est
  déjà ouvert » inchangé.
- Rien n'est lancé si une boîte de dialogue est ouverte.
- Canal fermé avant le redémarrage d'une mise à jour du launcher,
  puis recréé par la nouvelle version.
- Sécurité : canal nommé réservé à l'utilisateur courant
  (CurrentUserOnly), nom propre à la session et au SID, un seul
  message de 256 octets max en 2 s, identifiant de jeu uniquement
  (validé puis comparé à launcher.json, aucun chemin ni argument).
2026-10-24 17:05:25 +02:00
nicolas b6d364901a Réparer : contrôle des raccourcis + pas de double lancement du jeu
- Réparer vérifie les raccourcis du Bureau et l'entrée « Applications
  installées » : redirection vers l'emplacement actuel du launcher
  (sans question si l'ancien exe a disparu, avec confirmation s'il
  s'agit d'une autre copie encore présente). Fonctionne hors ligne.
  Le résultat est indiqué dans le bandeau.
- JOUER ne lance pas le jeu si son exécutable tourne déjà dans la
  session Windows (lancé hors du launcher ou avant un redémarrage).
- Nouvelle méthode LaunchFromShortcut() : lance le jeu s'il est prêt,
  attend la fin d'une vérification en cours, sinon explique dans le
  bandeau pourquoi le jeu n'est pas lancé.
2026-10-24 17:04:56 +02:00
nicolas 434471dfc1 Raccourcis du Bureau : recherche par argument --game et redirection
- Un raccourci du jeu est reconnu à ses arguments « --game <GameId> »,
  même s'il a été renommé (Bureau de l'utilisateur, 300 raccourcis max).
- Au démarrage, les raccourcis vers un launcher disparu (déplacé ou
  renommé) sont redirigés automatiquement vers le launcher actuel.
- La redirection conserve l'icône si le joueur l'a personnalisée.
- IShortcutService : LauncherPath, FindDesktopShortcuts(),
  RetargetDesktopShortcut().
- IRegistryService : RefreshLauncherPath() ajouté à l'interface.
- Nouveau record DesktopShortcut.
2026-10-24 17:04:46 +02:00
nicolas 6512110eb0 Version 1.5 du jeu 2026-10-24 16:44:59 +02:00
nicolas 1401d9909c Passage à la version 1.4.2 du jeu 2026-10-21 22:30:23 +02:00
nicolas 8fbb12e52e Ajout de l'argument --prune-objects qui permet de supprimer les fichiers du dossier objects\ qui ne sont plus référencés dans le manifest JSON. 2026-10-21 22:28:52 +02:00
fred 7e52faf831 Merge pull request 'Ajout d'un module de mise à jour automatique' (#1) from dev/1.1 into master
Reviewed-on: #1
Reviewed-by: Deville <fred@lanisys.fr>
2026-10-18 12:22:58 +02:00
182 changed files with 21030 additions and 373 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/
+96
View File
@@ -28,9 +28,41 @@ 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-catalog.conf = server\deploy\launcher-api-catalog.conf
server\deploy\launcher-api-games.conf = server\deploy\launcher-api-games.conf
server\deploy\launcher-api.service = server\deploy\launcher-api.service
server\deploy\nginx-api.conf = server\deploy\nginx-api.conf
server\deploy\nginx-catalog.conf = server\deploy\nginx-catalog.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
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Modules", "src\GameLauncher.Modules\GameLauncher.Modules.csproj", "{9EBFA383-7928-46F6-8958-F4B38287E380}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Modules.Admin", "src\GameLauncher.Modules.Admin\GameLauncher.Modules.Admin.csproj", "{E4FD522A-F44B-4401-9A18-542D42136E6B}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -71,6 +103,62 @@ 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
{9EBFA383-7928-46F6-8958-F4B38287E380}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9EBFA383-7928-46F6-8958-F4B38287E380}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9EBFA383-7928-46F6-8958-F4B38287E380}.Debug|x64.ActiveCfg = Debug|x64
{9EBFA383-7928-46F6-8958-F4B38287E380}.Debug|x64.Build.0 = Debug|x64
{9EBFA383-7928-46F6-8958-F4B38287E380}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9EBFA383-7928-46F6-8958-F4B38287E380}.Release|Any CPU.Build.0 = Release|Any CPU
{9EBFA383-7928-46F6-8958-F4B38287E380}.Release|x64.ActiveCfg = Release|x64
{9EBFA383-7928-46F6-8958-F4B38287E380}.Release|x64.Build.0 = Release|x64
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Debug|x64.ActiveCfg = Debug|x64
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Debug|x64.Build.0 = Debug|x64
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Release|Any CPU.Build.0 = Release|Any CPU
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Release|x64.ActiveCfg = Release|x64
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Release|x64.Build.0 = Release|x64
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -80,6 +168,14 @@ 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}
{9EBFA383-7928-46F6-8958-F4B38287E380} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
{E4FD522A-F44B-4401-9A18-542D42136E6B} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {E2F3A4B5-C6D7-4E8F-9A0B-1C2D3E4F5A6B}
+230 -22
View File
@@ -32,9 +32,16 @@ Vous obtenez un `GameLauncher.exe` autonome (aucun runtime à installer) + `laun
| Projet | Rôle |
|---|---|
| `src/GameLauncher` | Application WPF (fenêtres, thème, ViewModels, registre, raccourci) |
| `src/GameLauncher.Core` | Logique pure, sans WPF : manifest, téléchargement, installation, mise à jour, vérification, désinstallation |
| `src/GameLauncher.Core` | Logique pure, sans WPF : manifest, téléchargement, installation, mise à jour, vérification, désinstallation, catalogue des jeux (`Catalog/`) |
| `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, catalogue et notes de version (modèle et limites) (voir §8) |
| `src/GameLauncher.ApiClient` | Client de la Launcher API : connexion par clé, requêtes signées, fichier de clé chiffré |
| `src/GameLauncher.Modules` | Socle des modules du launcher (sans WPF) : contrat d'un module, pages du menu d'administration, MVVM commun |
| `src/GameLauncher.Modules.Admin` | Module « Administration » : clés, connexion, invitation, verrouillage, tableau de bord ; modules « Catalogue des jeux » (`Catalog/`, page « Jeux ») et « Notes de version » (`Changelog/`) (vues dans `src\GameLauncher\Views\Admin` et `Themes\Admin.xaml`) |
| `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/
@@ -42,13 +49,17 @@ src/GameLauncher/
├─ launcher.json Configuration éditeur : un jeu, ou plusieurs (voir launcher.multi-jeux.example.json)
├─ 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
│ Changelog.xaml (modèles des notes de version), Admin.xaml (menu d'administration,
│ dont les pages « Jeux » et « Notes de version »)
├─ Views/ MainWindow (+ liste des jeux), InstallDialog, SettingsWindow, ChangelogWindow, ModernDialog, TrayMenuWindow
│ └─ Admin/ AdminWindow, LoginView, EnrollView (menu d'administration ; ViewModels dans GameLauncher.Modules.Admin)
├─ 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, GameScopedServices, interfaces
└─ Platform/ DialogService, WindowService (implémentations WPF)
├─ Services/ ThemeManager, RegistryService, ShortcutService, InstanceChannel (raccourci → launcher
│ déjà ouvert), GameScopedServices, AdminWindowService (menu d'administration), interfaces
└─ Platform/ DialogService, WindowService (implémentations WPF), TrayIcon (zone de notification),
ModuleDialogs, WpfUiDispatcher (services offerts aux modules)
```
## 3. Publier une version du jeu (ManifestBuilder)
@@ -61,12 +72,20 @@ dotnet run --project tools\ManifestBuilder -c Release -- ^
--exe MonJeu.exe ^
--game-id MonJeu ^
--notes notes.txt ^
--remote-manifest https://launcher.example/monjeu/manifest.json ^
--exclude "*.pdb;*.log"
```
L'historique des notes est conservé d'une version à l'autre (30 versions) et affiché dans le
launcher (panneau « Notes de version », bouton **Agrandir** pour les lire en grand).
`--remote-manifest` (recommandé, déjà dans `tools\publish-example.bat`) : l'historique est repris du
manifest **en ligne**, et non du `manifest.json` local. Les notes corrigées depuis le menu
d'administration (page « Notes de version », §8) ne sont donc pas écrasées à la publication
suivante. Pas de manifest en ligne (première publication) : historique local. Serveur injoignable :
la publication s'arrête (rien n'est perdu ; réessayez, ou retirez l'option). Envoyez ensuite les
fichiers sans attendre. Republier une version sans `--notes` garde ses notes existantes.
### Format de `notes.txt`
Texte simple, à écrire comme une note de mise à jour (exemple complet : `tools/notes.txt`) :
@@ -151,6 +170,47 @@ enregistrées localement.
Si le dossier a disparu ou est presque vide → réinstallation via l'archive ZIP dans le même
dossier. Sinon → vérification complète + téléchargement des seuls fichiers défectueux.
La réparation contrôle aussi les **raccourcis du Bureau** du jeu et l'entrée « Applications
installées » de Windows (voir « Raccourcis du Bureau » ci-dessous) : s'ils désignent un ancien
emplacement du launcher, ils sont redirigés vers le launcher actuel (même hors ligne).
### Raccourcis du Bureau et déplacement du launcher
Le raccourci créé à l'installation pointe vers **le launcher** (`GameLauncher.exe --game <GameId>`),
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é. |
| Launcher déplacé ou renommé, raccourci vers l'ancien emplacement (fichier disparu) | Au prochain démarrage du launcher depuis son nouvel emplacement : raccourci redirigé automatiquement, `LauncherPath` et bouton « Désinstaller » de Windows mis à jour |
| 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 » (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
launcher est enregistré dans le registre (`LauncherPath`, `UninstallString`) pour chaque jeu
installé et mis à jour à chaque démarrage. Les paramètres (`%AppData%`) ne dépendent pas de
l'emplacement du launcher ; un `launcher.json` posé à côté de l'exe doit être déplacé avec lui.
Lancer le jeu, avec ou sans raccourci, ne démarre pas une seconde copie du jeu si son exécutable
tourne déjà (lancé directement ou avant un redémarrage du launcher).
**Communication entre instances** : canal nommé local réservé à l'utilisateur courant
(`PipeOptions.CurrentUserOnly`, nom propre à la session et à l'utilisateur), une seule commande
bornée (256 octets, 2 s) contenant uniquement un identifiant de jeu, validé puis comparé aux jeux
de `launcher.json`. Aucun chemin ni argument n'est transmis. Le canal est fermé avant le
redémarrage du launcher lors de sa mise à jour, puis recréé par la nouvelle version.
### Localiser le jeu
Sélection d'un dossier : l'exécutable est recherché dans le dossier et ses sous-dossiers
(2 niveaux). Le jeu est enregistré dans le registre puis analysé : s'il diffère de la dernière
@@ -159,17 +219,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
@@ -177,19 +271,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
@@ -201,8 +341,9 @@ sont repris de ceux du jeu affiché par défaut.
| Vignette d'un jeu (liste des jeux) | `launcher.json` → `"icon"` (image carrée) ; sinon fond du thème + initiales |
| Icône | Remplacez `Assets/launcher.ico` |
| 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` |
| Textes | `launcher.json` (nom, accroche), `ViewModels/GameViewModel.cs` ; sans recompiler : menu d'administration › **Jeux** (§8) |
| 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
@@ -234,18 +375,18 @@ liste des jeux apparaît alors à gauche de la fenêtre (masquée s'il n'y a qu'
rien ne change pour un jeu déjà installé avec la version mono-jeu.
* Les jeux sont vérifiés en parallèle ; une pastille sur la vignette signale une mise à jour,
une opération en cours ou un problème. Un message concernant un jeu sélectionne ce jeu.
* Raccourci du Bureau : `GameLauncher.exe --game <GameId>` (ouvre le launcher sur ce jeu).
* Raccourci du Bureau : `GameLauncher.exe --game <GameId>` (ouvre le launcher sur ce jeu et le lance s'il est prêt).
* Fermer le launcher pendant des téléchargements demande une seule confirmation pour tous les jeux.
### Options de la ligne de commande
| Option | Rôle |
|---|---|
| `--game <GameId>` | Ouvre le launcher sur ce jeu |
| `--game <GameId>` | Ouvre le launcher sur ce jeu, vérifie les mises à jour puis lance le jeu s'il est prêt ; si le launcher est déjà ouvert, lui demande de lancer ce jeu (voir « Raccourcis du Bureau ») |
| `--uninstall [GameId]` | Désinstallation (utilisée par Windows) |
| `--config <fichier>` | Utilise un autre `launcher.json` (tests) |
| `--config <fichier>` | Utilise un autre `launcher.json` (tests). En Release, ses `apiUrl` et `catalogUrl` sont ignorées (seules celles intégrées à l'exe comptent) |
| `--capture <dossier>` | Enregistre une capture PNG par jeu (+ ses paramètres, + paramètres du launcher), sans rien installer, puis quitte |
| `--updated <pid> <événement> <version>` | Interne : démarrage de la nouvelle version après une mise à jour du launcher (§7) |
| `--updated <pid> <événement> <version>` | Interne : démarrage de la nouvelle version après une mise à jour du launcher (§7). Le jeu de `--game` n'est alors lancé que si `--launch` est aussi présent (mise à jour faite au démarrage depuis un raccourci), jamais après une mise à jour demandée depuis les paramètres |
## 6. Bon à savoir
@@ -333,5 +474,72 @@ 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.
Phase 2 livrée : le **menu d'administration** du launcher (connexion, invitation, tableau de bord).
Phase 3 livrée : la page **Jeux** (catalogue des jeux, voir plus bas). Phase 4 livrée : la page
**Notes de version**. Les autres pages de gestion arrivent dans les phases suivantes.
* **Ouvrir le menu** : `Ctrl+Maj+Alt+A` dans la fenêtre du launcher, ou **5 clics rapides** (en
moins de 3 s) sur le logo en haut à gauche. Accès discret, pas secret : seule la connexion par clé,
vérifiée par le serveur, protège l'administration.
* **Adresse du serveur** : `"apiUrl"` dans `src\GameLauncher\launcher.json` (racine du fichier,
https). En Release, seule la valeur **intégrée à l'exe** est utilisée ; en Debug, celle du
`launcher.json` utilisé (ex. `--config` avec `http://127.0.0.1:5080/api/` pour un serveur local).
Sans `apiUrl`, le menu indique qu'il n'est relié à aucun serveur.
* **Se connecter** : choisir sa clé (dossier `%USERPROFILE%\.launcher-keys`, ou « Choisir un autre
fichier de clé… »), saisir sa phrase de passe. Verrouillage automatique après 10 min sans activité,
quand Windows est verrouillé, ou avec `Ctrl+L` ; fermer la fenêtre efface la clé de la mémoire.
* **« J'ai une invitation »** : crée la clé sur ce PC et l'enregistre avec le code reçu, puis
affiche l'**empreinte** à dicter à la personne qui approuve. Sans code : clé seule (premier
propriétaire, à déclarer sur le VPS). Deux rangements au choix :
* **fichier chiffré** (phrase de passe, 12 caractères min.) : copiable et sauvegardable ;
* **puce de sécurité (TPM 2.0) de ce PC** (code PIN, 6 caractères min.) : la clé est créée dans la
puce et ne peut pas être copiée. Proposé seulement après vérification réelle de la puce. Elle
disparaît avec la puce (carte mère, réinitialisation, Windows réinstallé) : gardez aussi une
clé fichier de secours. Sur le disque : `admin.tpmkey` (référence, sans secret) et `admin.pub`.
Écran de connexion : « Supprimer cette clé de la puce » (puis révoquer la clé sur le serveur) ;
après trop de codes PIN faux, un décompte indique quand la puce acceptera de nouveau un code.
* **Page « Jeux »** (permission `games.edit`) : nom affiché, accroche, description, liens (Discord,
YouTube…), site, thème intégré, icône et fond, fréquence des recherches de mise à jour, jeu masqué,
nom du launcher, jeu affiché au premier lancement, ordre des jeux. Champ vide = valeur de
`launcher.json`. « Publier » contrôle les valeurs puis écrit `catalog.json` sur le serveur (requête
signée) ; si quelqu'un a publié entre-temps, rien n'est écrasé et la page propose de recharger.
Les jeux eux-mêmes (identifiant, manifest, exécutable, dossiers) restent dans `launcher.json` ;
le nom affiché ne change ni le raccourci, ni le registre, ni les dossiers.
* **Page « Notes de version »** (permission `changelog.edit`) : corrige les notes des versions **déjà
publiées** dans le `manifest.json` du serveur. Liste des versions, en-tête (titre, sous-titre,
introduction), sections › éléments (modification ou commentaire, étiquette, sous-points, note),
boutons ↑ ↓ pour l'ordre, **Aperçu** avec la présentation exacte du launcher. Mêmes limites que le
launcher, vérifiées avant l'envoi. « Enregistrer » ne change que les notes (fichiers, archive,
version et exécutable restent identiques) ; si le manifest a changé entre-temps (nouvelle version
publiée), rien n'est écrasé et la page propose de recharger. Une nouvelle version et ses notes se
publient toujours avec ManifestBuilder (§3, `--remote-manifest`). Installation sur le VPS :
`server/deploy/LISEZMOI.md`, étape 3 ter.
* **Catalogue côté joueur** : `"catalogUrl"` dans `launcher.json` (racine, https ; en Release, seule
la valeur intégrée à l'exe compte). Lu au démarrage sans le retarder (2,5 s au plus, sinon appliqué
au démarrage suivant), mis en cache (fonctionne hors ligne), toute valeur invalide ignorée. Images :
PNG/JPEG du même serveur, déposées dans `catalog/images/` (nouveau nom à chaque changement).
Installation sur le VPS : `server/deploy/LISEZMOI.md`, étape 3 bis.
* **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) ; créer sa clé et se connecter : aussi depuis le launcher |
| Lancer le serveur en local (F5) | Projet `GameLauncher.Server`, profil *Development* : `http://127.0.0.1:5080/api/` |
+475
View File
@@ -0,0 +1,475 @@
# 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 : fichier │ │ ├─ /launcher/… mise à jour launcher │
│ │ chiffré, ou puce TPM │ │ ├─ /catalog/… infos des jeux (ph. 3)│
│ │ du PC (non exportable) │ │ └─ /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 ; **affichage** des modules (vues WPF du menu d'administration : `Views/Admin`, `Themes/Admin.xaml`) |
| `src/GameLauncher.Modules` | net8.0 | **Nouveau (phase 2).** Socle des modules, sans WPF : `ILauncherModule`, registre, pages d'administration, boîtes de dialogue abstraites, MVVM commun |
| `src/GameLauncher.Modules.Admin` | net8.0 | **Nouveau (phase 2).** Module « Administration » : services (clés, connexion, invitation, verrouillage) et ViewModels, testés sans interface |
| `src/GameLauncher.Core` | net8.0 | Logique du jeu : manifest, téléchargement, installation… ; **phase 3** : lecture, cache et application du catalogue (`Catalog/`) |
| `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 ; catalogue (phase 3) ; modèle et bornes des notes de version (phase 4) |
| `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`
(uniquement : le serveur n'utilise pas `Core`) ; `Core` → `Contracts` (règles du catalogue, phase 3) ;
`Modules` → `Core`, `ApiClient` ; `Modules.Admin` → `Modules` ; `GameLauncher` → `Core`, `Modules`,
`Modules.Admin`.
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 ✅)
La logique d'un module (services, ViewModels) est une bibliothèque **net8.0 sans WPF**, testée sans
interface ; le launcher WPF n'en fournit que l'**affichage** (un `DataTemplate` par type de page).
```csharp
public interface ILauncherModule
{
string Id { get; } // "admin", puis "news", "friends"…
string DisplayName { get; }
void Register(IModuleRegistry registry);
}
public interface IModuleRegistry
{
void AddAdminPanel(AdminPanelDescriptor panel); // page du menu d'administration
// Plus tard : AddPage (section de la fenêtre principale), services partagés…
}
public sealed record AdminPanelDescriptor(
string Id, string Title, string Description, string IconKey,
string? RequiredPermission, // null : toute personne connectée
int Order, Func<IAdminContext, AdminPanelViewModel> CreateViewModel);
```
* `ModuleCatalog` rassemble les modules (déclarés dans `App.CreateModules()`), vérifie l'unicité des
identifiants et le format des permissions, et donne les pages **autorisées par les permissions**
renvoyées par le serveur (le serveur reste seul juge : masquer une page n'est qu'un confort).
* Le **menu d'administration** est une fenêtre « coquille » : à gauche l'administrateur connecté, les
pages des modules, le temps restant avant verrouillage et le bouton « Verrouiller » ; à droite la
page choisie.
* `IAdminContext` donne à chaque page : la session (`AdminSession` : requêtes signées), le client de
l'API, les jeux déclarés dans `launcher.json`, les boîtes de dialogue (`IModuleDialogs`), la
navigation entre pages et le signalement d'une session perdue (retour à la connexion).
`AdminPanelViewModel.RunAsync` gère « occupé », les messages d'erreur et la session expirée.
* Ajouter une page à un module futur : une classe `AdminPanelViewModel` + son descripteur dans le
module, et son `DataTemplate` dans `Themes/Admin.xaml` (ou un dictionnaire dédié fusionné dans
`App.xaml`). La fenêtre d'administration elle-même ne change pas.
## 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 :
* **fichier chiffré** (phase 2 ✅) : 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.
Dossier proposé : `%USERPROFILE%\.launcher-keys` (celui de `tools\admin-key.bat`). La clé de
signature du launcher (`launcher-signing*.key`), rangée dans le même dossier, n'est jamais
proposée ni acceptée. Une clé n'écrase jamais un fichier existant ;
* **puce de sécurité TPM 2.0** (phase 2b ✅, au choix lors de « J'ai une invitation ») : la clé
ECDSA P-256 est **générée dans la puce** par le fournisseur Windows « Microsoft Platform Crypto
Provider » et n'en sort jamais (non exportable) ; la puce signe elle-même. Clé de l'utilisateur
Windows courant, protégée par un **code PIN** (6 caractères minimum, saisi dans le launcher,
vérifié par la puce, qui se bloque temporairement après plusieurs codes faux). Sur le disque,
seul un fichier de référence `*.tpmkey` (nom de la clé dans la puce + clé publique, aucun
secret) et le `.pub` habituel. À la connexion, la clé publique de la puce est comparée à celle
de la référence. Le choix n'est **dégrisé qu'après une vérification réelle de la puce**
(création, signature et suppression d'une clé de test). Rien ne change côté serveur : même
format de clé publique P-256, mêmes signatures. Limites : la clé ne peut pas être sauvegardée
et disparaît avec la puce (carte mère, réinitialisation du TPM, Windows réinstallé) — garder
une clé « fichier » de secours ; le serveur ne peut pas prouver qu'une clé est dans une puce
(pas d'attestation) : c'est une protection pour l'administrateur, pas une règle du serveur ;
le blocage anti-essais est commun à toute la puce (les codes faux comptent aussi pour le code
PIN Windows Hello) ; avec un profil Windows itinérant, la clé n'est utilisable que sur le PC
qui l'a créée. Le fichier `.tpmkey` ne désigne que des clés nommées `GameLauncher.Admin.*`
du fournisseur de la puce, et sa clé publique est comparée à celle de la puce avant tout
essai du code PIN.
Écran de connexion : **« Supprimer cette clé de la puce »** (clés TPM uniquement, après
confirmation ; efface la clé de la puce, le `.tpmkey` et le `.pub` — à révoquer aussi sur le
serveur ; les clés fichier se suppriment à la main). Après un code faux, le nombre d'essais
restants est affiché ; si la puce est bloquée, un **décompte** indique quand elle acceptera de
nouveau un code (état lu dans la puce par TPM2_GetCapability via le service TBS : compteur,
maximum, intervalle de « guérison » ; estimation, PC allumé). Si cet état n'est pas lisible sur
le PC, une explication générale est affichée et un nouvel essai reste possible.
* Windows Hello (visage, empreinte) : écarté pour l'instant, son API ne crée que des clés RSA,
ce qui demanderait de faire évoluer le serveur.
### 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).
* **Côté launcher** (phase 2) : la clé déchiffrée n'existe en mémoire que pendant la session. Le menu
se **verrouille** (session fermée, clé effacée, retour à la connexion) après **10 min sans activité**
dans la fenêtre, à la fin des 8 h, quand la **session Windows est verrouillée** (Win+L, veille),
sur `Ctrl+L` ou le bouton « Verrouiller », et si le serveur refuse la session (révocation). Tant
que l'administrateur est actif, la session est prolongée sur le serveur toutes les 4 min. Fermer
la fenêtre ou le launcher efface la clé, puis déconnecte la session. La phrase de passe n'est
jamais conservée (champ effacé dès la tentative de connexion).
* L'adresse de l'API (`apiUrl`) n'est lue, dans la version Release, que dans le `launcher.json`
**intégré à l'exe** : un fichier posé à côté (ou `--config`) ne peut pas envoyer les
administrateurs vers un autre serveur. Une valeur invalide est ignorée (le launcher des joueurs
démarre toujours) et expliquée à l'ouverture du menu.
### 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.
(Phase 2 : étapes 3 et 4 dans le launcher ; création de l'invitation et approbation avec
`tools\admin-key.bat` jusqu'à la page « Administrateurs » de la phase 6.)
**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. Comme `apiUrl`, la liste des jeux que le
menu d'administration peut modifier est lue, en Release, dans le `launcher.json` intégré à l'exe.
Les champs 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).
### 9.1 Mise en œuvre (phase 3 ✅)
**Format** (`Contracts/Catalog`) : `{ "format": "gamelauncher-catalog", "version": 1, "revision", "updatedAt",
"launcher": { "launcherName", "defaultGame", "gameOrder" }, "games": { "<gameId>": { "gameName", "tagline",
"description", "websiteUrl", "links": [{ "kind", "label", "url" }], "theme", "icon", "background",
"updateCheckIntervalMinutes", "hidden" } } }`. Un champ absent = valeur de `launcher.json`.
`CatalogRules` (partagé) borne tout : 48 Ko (sous la limite de 64 Ko des requêtes de l'API), 50 jeux,
noms 80 caractères, accroche 200, description 2000 caractères / 40 lignes, 8 liens, adresses https
(500 caractères, sans identifiants), images `.png`/`.jpg` du **même hôte que le catalogue**, thème =
identifiant (`[a-z0-9-]`, jamais un chemin), intervalle 0 ou 5 à 1440. Le serveur **refuse** une
valeur invalide (400) ; le launcher l'**ignore** (valeur de `launcher.json`).
**Serveur** (module `Catalog`, `Storage/CatalogStore`) :
* `GET v1/catalog` (administrateur connecté) : catalogue publié, publication activée ou non, adresse publique.
* `PUT v1/catalog` (`games.edit` + requête signée) : `{ baseRevision, catalog }`. Révision différente
de celle publiée → 409 `revision_conflict` (rien n'est écrasé) ; contrôle `CatalogRules` ; écriture
de la référence privée (`/var/lib/launcher-api/catalog.json` + `.bak`), puis du **fichier public**
(fichier temporaire du même dossier, droits 644 fixés explicitement malgré `UMask=0077`, renommage
atomique) ; en cas d'échec du fichier public, la référence privée est remise en état (503) ;
historique des 20 dernières versions ; audit `catalog.publish` (jeux modifiés).
* Réglages `LauncherApi:Catalog:PublicFile` (vide = publication désactivée, 503 `not_configured`) et
`PublicUrl` (défaut : `PublicOrigin` + `/catalog/catalog.json`). Au démarrage : chemin absolu `.json`,
dossier existant, dossier public et données privées séparés (aucun dans l'autre, liens résolus).
* VPS : dossier public (ex. `/srv/exovival/www/catalog`) autorisé en écriture par un complément
systemd (`ReadWritePaths`), `catalog.json` servi par nginx sans cache (`server/deploy/LISEZMOI.md`, étape 3 bis).
**Launcher (joueur)** (`Core/Catalog`) :
* `catalogUrl` lue, en Release, **uniquement** dans le `launcher.json` intégré (comme `apiUrl`).
* Démarrage : le cache est appliqué immédiatement ; le catalogue à jour est téléchargé en parallèle
de la vérification de mise à jour du launcher et appliqué s'il arrive en 2,5 s (nouvelles images :
1,5 s de plus) ; sinon il est mis en cache pour le démarrage suivant.
* Téléchargements bornés (48 Ko, images 4 Mo), redirections suivies **uniquement** vers le même
serveur (aucune requête vers un autre hôte), images contrôlées (signature PNG/JPEG), mises en cache
sous l'empreinte de leur adresse (changer d'image = nouveau nom de fichier), fonds réduits à
3840 px au chargement.
* `CatalogMerger` : valeurs de `launcher.json` restaurées avant chaque application ; jeux inconnus
ignorés ; thèmes intégrés uniquement ; images du cache uniquement ; jeu masqué toujours affiché s'il
est installé ou demandé (raccourci) ; au moins un jeu affiché. Le nom du catalogue est un **nom
affiché** : registre, raccourci, dossier d'installation proposé et dossier de données gardent le
`gameName` de `launcher.json`.
* Fiche du jeu : description et boutons de liens (icônes génériques : site, forum, vidéo…).
**Menu d'administration** : module `CatalogAdminModule` (permission `games.edit`), page « Jeux » :
liste « Launcher » + jeux (ordre ↑ ↓), champs vides = valeur de `launcher.json` (rappelée sous le
champ), pastille « modifié », contrôle complet avant l'envoi (mêmes règles que le serveur), saisie
suspendue pendant l'envoi, conflit de révision signalé avec rechargement proposé, bandeau si la
publication n'est pas configurée.
## 10. Notes de version (phase 4 ✅)
* Éditeur graphique dans le menu admin (page « Notes de version », module `ChangelogAdminModule`,
permission `changelog.edit`) : liste des versions › en-tête (titre, sous-titre, introduction) ›
sections › éléments (modification ou commentaire, étiquette, sous-points, note), ordre ↑ ↓,
**aperçu en direct avec les mêmes modèles que le launcher** (`Themes/Changelog.xaml`) et les mêmes
bornes (`ChangelogLimits`). Une version à l'ancien format (liste à plat) est présentée en section(s)
sans titre et enregistrée au format enrichi.
* On édite **les versions existantes** uniquement ; la création d'une version reste au `.bat`.
* **Modèle partagé** : `ChangelogEntry`/`ChangelogSection`/`ChangelogItem` et `ChangelogLimits` sont
dans `Contracts` (`GameLauncher.Contracts.Changelog`), utilisés par le launcher, ManifestBuilder, le
menu d'administration et le serveur (qui ne référence pas `Core`). `ChangelogLimits.Check` signale
les dépassements sans rien tronquer ; `Normalize` tronque (texte + « … » dans la longueur maximale).
* **API** :
* `GET v1/games/{gameId}/changelog` (administrateur connecté) : notes du manifest publié (liste à
plat omise quand les sections existent) et **empreinte SHA-256** du manifest lu ;
* `PUT v1/games/{gameId}/changelog/{version}` (`changelog.edit` + requête signée) : notes d'**une**
version (60 Ko au plus, sous la limite de 64 Ko des requêtes) + empreinte de base. Identifiant et
version : caractères sûrs uniquement (jamais utilisés dans un chemin).
* **Serveur** (`Storage/ChangelogStore`) : manifest configuré par jeu (`LauncherApi:Games:<id>:ManifestFile`,
chemin absolu, dossier existant, séparé des données privées ; `gameId` du manifest vérifié).
Sous verrou : empreinte comparée (409 `revision_conflict` si le manifest a changé : nouvelle version
publiée, autre administrateur), contrôle des bornes (400), entrée de la version remplacée dans
l'arbre JSON (numéro, date et champs inconnus conservés, quelle que soit la casse ; liste à plat
régénérée pour les anciens launchers), budget de tout l'historique vérifié, copie de l'ancien
manifest dans les données privées (20 dernières), écriture d'un fichier temporaire caché du même
dossier avec les droits du manifest existant, **empreinte revérifiée juste avant** le renommage
atomique. Refus (503) si le manifest est un lien symbolique, ou si le dossier n'a pas le bit setgid
alors que nginx lit le manifest par son groupe. Audit `changelog.edit`.
* **Droits sur le VPS** : le service doit pouvoir écrire dans le dossier du jeu (complément systemd
`ReadWritePaths`, `server/deploy/LISEZMOI.md` étape 3 ter). C'est un compromis : un service compromis
pourrait alors modifier les fichiers du jeu servis aux joueurs (le manifest n'est pas encore signé,
voir phase 7). L'autorisation est limitée au seul dossier du jeu ; sans `LauncherApi:Games`, rien
n'est modifiable et le dossier reste en lecture seule pour le service.
* **Cohérence avec ManifestBuilder** : option `--remote-manifest <url>` (ajoutée à
`tools\publish-example.bat`) : l'historique est repris du manifest **en ligne**, seule la nouvelle
version vient de `notes.txt`. 404 = première publication (historique local) ; toute autre erreur
arrête la publication. Republier une version sans `--notes` garde ses notes existantes.
## 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 : socle des modules (`GameLauncher.Modules`), module Administration (`GameLauncher.Modules.Admin`), entrée discrète (Ctrl+Maj+Alt+A ou 5 clics sur le logo), fenêtre d'administration (navigation, session, verrouillage auto), clé en fichier chiffré, connexion, « J'ai une invitation », tableau de bord | ✅ |
| **2b** | Clé dans la puce TPM 2.0 (non exportable, code PIN vérifié par la puce), au choix en plus du fichier chiffré | ✅ |
| **3** | Séparation fixe/modifiable : `catalog.json`, lecture + cache côté joueur, module serveur `Catalog`, page admin « Jeux » | ✅ |
| **4** | Éditeur de notes de version + module serveur `Changelog` + option `--remote-manifest` de ManifestBuilder | ✅ |
| **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. Pour les joueurs, rien ne
change : le menu d'administration est discret et ne contacte le serveur qu'une fois ouvert.
## 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,39 @@
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(),
new Modules.Catalog.CatalogModule(),
new Modules.Changelog.ChangelogModule()
};
}
/// <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,267 @@
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();
public CatalogSettings Catalog { get; set; } = new();
/// <summary>
/// Jeux dont le manifest.json publié peut être modifié par l'API (notes de version, phase 4), par
/// identifiant de jeu (GameId de launcher.json). Absent : notes non modifiables en ligne.
/// </summary>
public Dictionary<string, GameSettings> Games { 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;
}
/// <summary>Catalogue des jeux (informations modifiables), publié en fichier statique servi par nginx.</summary>
public sealed class CatalogSettings
{
/// <summary>
/// Fichier public écrit par le service (chemin absolu, ex. « /srv/exovival/www/catalog/catalog.json »).
/// Vide : publication désactivée (le catalogue reste consultable dans le menu d'administration).
/// Le dossier doit exister, appartenir au compte du service, et être autorisé en écriture par
/// systemd (ReadWritePaths, voir server/deploy/LISEZMOI.md).
/// </summary>
public string PublicFile { get; set; } = "";
/// <summary>Adresse publique du fichier (information). Vide : « PublicOrigin/catalog/catalog.json ».</summary>
public string PublicUrl { get; set; } = "";
}
/// <summary>Un jeu publié sur ce serveur.</summary>
public sealed class GameSettings
{
/// <summary>
/// manifest.json publié du jeu (chemin absolu dans le dossier servi par nginx, ex.
/// « /srv/exovival/www/exovivalzombie/manifest.json »). Son dossier doit être autorisé en écriture
/// par systemd (ReadWritePaths, voir server/deploy/LISEZMOI.md).
/// </summary>
public string ManifestFile { get; set; } = "";
}
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>Fichier public du catalogue en chemin absolu (null : publication désactivée), calculé par <see cref="Validate"/>.</summary>
public string? CatalogPublicFileFullPath { get; private set; }
/// <summary>Adresse publique du catalogue, calculée par <see cref="Validate"/>.</summary>
public string CatalogPublicUrl { get; private set; } = "";
/// <summary>
/// Hôte de <see cref="CatalogPublicUrl"/> : seul hôte autorisé pour les images du catalogue (le
/// launcher n'accepte que des images du même hôte que le catalogue).
/// </summary>
public string CatalogImageHost { get; private set; } = "";
/// <summary>manifest.json de chaque jeu configuré (chemins absolus), calculé par <see cref="Validate"/>.</summary>
public IReadOnlyDictionary<string, string> GameManifestFiles { get; private set; } =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
/// <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;
ValidateCatalog();
ValidateGames();
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 void ValidateCatalog()
{
CatalogPublicFileFullPath = null;
if (!string.IsNullOrWhiteSpace(Catalog.PublicFile))
{
var file = Catalog.PublicFile.Trim();
if (!Path.IsPathRooted(file) || !file.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException($"{Section}:Catalog:PublicFile doit être un chemin absolu vers un fichier .json.");
var full = Path.GetFullPath(file);
var directory = Path.GetDirectoryName(full);
if (directory is null || !Directory.Exists(directory))
throw new InvalidOperationException($"{Section}:Catalog:PublicFile : le dossier « {directory} » n'existe pas.");
EnsureSeparatedFromData(directory, "Catalog:PublicFile");
CatalogPublicFileFullPath = full;
}
if (string.IsNullOrWhiteSpace(Catalog.PublicUrl))
{
CatalogPublicUrl = NormalizedOrigin + "/catalog/catalog.json";
}
else if (Uri.TryCreate(Catalog.PublicUrl.Trim(), UriKind.Absolute, out var url) &&
(url.Scheme == Uri.UriSchemeHttps || (url.Scheme == Uri.UriSchemeHttp && url.IsLoopback)))
{
CatalogPublicUrl = url.AbsoluteUri;
}
else
{
throw new InvalidOperationException($"{Section}:Catalog:PublicUrl doit être une adresse https://.");
}
CatalogImageHost = new Uri(CatalogPublicUrl).Host;
}
private void ValidateGames()
{
var files = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var (gameId, game) in Games)
{
var name = $"Games:{gameId}:ManifestFile";
if (!Contracts.Catalog.CatalogRules.IsValidGameId(gameId))
throw new InvalidOperationException($"{Section}:Games : identifiant de jeu invalide « {gameId} ».");
if (string.IsNullOrWhiteSpace(game?.ManifestFile)) continue; // jeu déclaré sans manifest : ignoré
var file = game.ManifestFile.Trim();
if (!Path.IsPathRooted(file) || !file.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException($"{Section}:{name} doit être un chemin absolu vers le manifest .json du jeu.");
var full = Path.GetFullPath(file);
var directory = Path.GetDirectoryName(full);
if (directory is null || !Directory.Exists(directory))
throw new InvalidOperationException($"{Section}:{name} : le dossier « {directory} » n'existe pas.");
EnsureSeparatedFromData(directory, name);
if (files.Values.Contains(full, StringComparer.Ordinal))
throw new InvalidOperationException($"{Section}:{name} : ce manifest est déjà utilisé par un autre jeu.");
files[gameId] = full;
}
GameManifestFiles = files;
}
/// <summary>
/// Un dossier public (servi par nginx) et le dossier des données privées ne doivent pas être l'un
/// dans l'autre (liens symboliques résolus).
/// </summary>
private void EnsureSeparatedFromData(string directory, string name)
{
var publicDirectory = RealPath(directory);
var dataDirectory = RealPath(DataDirectoryFullPath);
if (IsSameOrInside(publicDirectory, dataDirectory) || IsSameOrInside(dataDirectory, publicDirectory))
throw new InvalidOperationException(
$"{Section}:{name} : le dossier public et le dossier des données privées doivent être séparés (aucun des deux dans l'autre).");
}
/// <summary>Chemin absolu, dernier élément résolu s'il s'agit d'un lien symbolique.</summary>
private static string RealPath(string directory)
{
var full = Path.GetFullPath(directory).TrimEnd(Path.DirectorySeparatorChar);
try
{
var target = new DirectoryInfo(full).ResolveLinkTarget(returnFinalTarget: true);
return target is null ? full : Path.GetFullPath(target.FullName).TrimEnd(Path.DirectorySeparatorChar);
}
catch (IOException)
{
return full;
}
}
private static bool IsSameOrInside(string path, string parent)
{
var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
return string.Equals(path, parent, comparison) ||
path.StartsWith(parent + Path.DirectorySeparatorChar, comparison);
}
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,93 @@
using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
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.Catalog;
/// <summary>
/// Catalogue des jeux (informations MODIFIABLES : nom affiché, accroche, présentation, liens, thème
/// intégré, images, intervalle de mise à jour, ordre et visibilité). Les jeux restent déclarés dans le
/// launcher.json intégré à l'exe : le launcher ignore les jeux qu'il ne connaît pas.
/// • GET : tout administrateur connecté (le contenu est public de toute façon) ;
/// • PUT : permission games.edit, requête signée, contrôle complet (<see cref="CatalogRules"/>),
/// révision attendue (409 si quelqu'un a publié entre-temps), audit.
/// </summary>
public sealed class CatalogModule : IApiModule
{
public string Name => "Catalog";
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) =>
services.AddSingleton<CatalogStore>();
public void MapEndpoints(IEndpointRouteBuilder api)
{
api.MapGet(ApiRoutes.Catalog, Get).RequireAdmin();
api.MapPut(ApiRoutes.Catalog, Publish).RequirePermission(AdminPermissions.GamesEdit).RequireSignedRequest();
}
private static IResult Get(CatalogStore store, IOptions<LauncherApiOptions> options) =>
Results.Json(State(store.Read(), store, options.Value), ApiJson.Options);
private static IResult Publish(CatalogUpdateRequest request, HttpContext context, CatalogStore store, AuditLog audit,
TimeProvider time, IOptions<LauncherApiOptions> options, ILogger<CatalogModule> logger)
{
if (request.Catalog is null) return ApiResults.BadRequest("Catalogue absent.");
if (request.BaseRevision < 0) return ApiResults.BadRequest("Révision invalide.");
var errors = new List<string>();
var catalog = CatalogRules.Normalize(request.Catalog with
{
Format = CatalogDocument.FormatName,
Version = CatalogDocument.CurrentVersion,
Revision = 0
}, options.Value.CatalogImageHost, errors);
if (errors.Count > 0)
return ApiResults.BadRequest(string.Join(" ", errors.Take(5)) + (errors.Count > 5 ? $" (+{errors.Count - 5} autres)" : ""));
CatalogDocument published, previous;
try
{
(published, previous) = store.Publish(catalog, request.BaseRevision, time.GetUtcNow());
}
catch (IOException ex)
{
logger.LogError(ex, "Publication du catalogue");
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
"Le serveur n'a pas pu écrire le catalogue public : vérifiez le dossier et ses droits (server/deploy/LISEZMOI.md).");
}
var actor = context.Admin();
audit.Write("catalog.publish", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
$"révision {published.Revision}", Describe(previous, published));
return Results.Json(State(published, store, options.Value), ApiJson.Options);
}
private static CatalogState State(CatalogDocument catalog, CatalogStore store, LauncherApiOptions options) => new()
{
Catalog = catalog,
PublishingEnabled = store.PublishingEnabled,
PublicUrl = options.CatalogPublicUrl
};
/// <summary>Résumé pour l'audit : jeux et réglages modifiés.</summary>
private static string Describe(CatalogDocument before, CatalogDocument after)
{
var changed = new List<string>();
if (Serialize(before.Launcher) != Serialize(after.Launcher)) changed.Add("launcher");
foreach (var id in before.Games.Keys.Union(after.Games.Keys, StringComparer.OrdinalIgnoreCase).Order(StringComparer.OrdinalIgnoreCase))
{
before.Games.TryGetValue(id, out var a);
after.Games.TryGetValue(id, out var b);
if (Serialize(a) != Serialize(b)) changed.Add(id);
}
return changed.Count == 0 ? "aucun changement" : "modifié : " + string.Join(", ", changed.Take(20));
}
private static string Serialize(object? value) => value is null ? "" : JsonSerializer.Serialize(value, ApiJson.Options);
}
@@ -0,0 +1,90 @@
using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Security;
using GameLauncher.Server.Storage;
namespace GameLauncher.Server.Modules.Changelog;
/// <summary>
/// Notes de version des jeux (éditeur du menu d'administration) : modification des versions EXISTANTES
/// dans le manifest.json publié de chaque jeu (LauncherApi:Games). La création d'une version reste
/// l'affaire de ManifestBuilder (et de la publication).
/// • GET : tout administrateur connecté (le manifest est public de toute façon) ;
/// • PUT (une version) : permission changelog.edit, requête signée, mêmes bornes que le launcher,
/// empreinte du manifest attendue (409 si le manifest a changé entre-temps), audit.
/// </summary>
public sealed class ChangelogModule : IApiModule
{
public string Name => "Changelog";
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) =>
services.AddSingleton<ChangelogStore>();
public void MapEndpoints(IEndpointRouteBuilder api)
{
api.MapGet(ApiRoutes.GameChangelogPattern, Get).RequireAdmin();
api.MapPut(ApiRoutes.GameChangelogEntryPattern, Save).RequirePermission(AdminPermissions.ChangelogEdit).RequireSignedRequest();
}
private static IResult Get(string gameId, ChangelogStore store, ILogger<ChangelogModule> logger)
{
if (!CatalogRules.IsValidGameId(gameId)) return ApiResults.BadRequest("Identifiant de jeu invalide.");
try
{
return Results.Json(store.Read(gameId), ApiJson.Options);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException)
{
logger.LogError(ex, "Lecture du manifest de {GameId}", gameId);
return ApiResults.Error(503, ApiErrorCodes.NotConfigured, ex is InvalidDataException
? $"Le serveur n'a pas pu lire le manifest de « {gameId} » : {ex.Message}"
: $"Le serveur n'a pas pu lire le manifest de « {gameId} » : vérifiez le dossier du jeu et ses droits (server/deploy/LISEZMOI.md, étape 3 ter).");
}
}
private static IResult Save(string gameId, string version, ChangelogUpdateRequest request, HttpContext context,
ChangelogStore store, AuditLog audit, ILogger<ChangelogModule> logger)
{
if (!CatalogRules.IsValidGameId(gameId)) return ApiResults.BadRequest("Identifiant de jeu invalide.");
if (!ChangelogLimits.IsEditableVersion(version)) return ApiResults.BadRequest("Numéro de version invalide.");
if (request.Entry is null) return ApiResults.BadRequest("Notes de version absentes.");
if (string.IsNullOrWhiteSpace(request.BaseSha256) || request.BaseSha256.Length != 64)
return ApiResults.BadRequest("Empreinte du manifest absente ou invalide.");
if (!string.Equals(request.Entry.Version, version, StringComparison.Ordinal))
return ApiResults.BadRequest("La version des notes ne correspond pas à l'adresse.");
var errors = ChangelogLimits.Check(request.Entry);
if (errors.Count > 0)
return ApiResults.BadRequest(string.Join(" ", errors.Take(5)) + (errors.Count > 5 ? $" (+{errors.Count - 5} autres)" : ""));
ChangelogState state;
ChangelogEntry previous;
try
{
(state, previous) = store.Save(gameId, version, request.Entry, request.BaseSha256);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException)
{
logger.LogError(ex, "Modification des notes de {GameId} {Version}", gameId, version);
return ApiResults.Error(503, ApiErrorCodes.NotConfigured, ex switch
{
InvalidDataException => $"Le serveur n'a pas pu lire le manifest : {ex.Message}",
ManifestWriteRefusedException => $"Modification refusée : {ex.Message} (server/deploy/LISEZMOI.md, étape 3 ter).",
_ => "Le serveur n'a pas pu modifier le manifest : vérifiez le dossier du jeu et ses droits (server/deploy/LISEZMOI.md, étape 3 ter)."
});
}
var actor = context.Admin();
var after = state.Entries.FirstOrDefault(e => e.Version == version);
audit.Write("changelog.edit", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
$"{state.GameId} {version}",
$"{Count(previous)} → {Count(after)} élément(s), manifest {state.ManifestSha256[..12]}");
return Results.Json(state, ApiJson.Options);
}
private static int Count(ChangelogEntry? entry) => entry is null ? 0 : ChangelogLimits.DisplayedItems(entry);
}
@@ -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,173 @@
using System.Text;
using System.Text.Json;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Server.Hosting;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Storage;
/// <summary>
/// Catalogue des jeux (informations modifiables).
/// • Référence : catalog.json dans le dossier des données privées (+ historique des 20 dernières révisions).
/// • Publication : copie dans le fichier PUBLIC servi par nginx (LauncherApi:Catalog:PublicFile),
/// écrite atomiquement (fichier temporaire du même dossier puis renommage), lisible par tous (0644).
/// • Contrôle des modifications concurrentes : une publication précise la révision sur laquelle elle
/// a été préparée ; si quelqu'un a publié entre-temps, elle est refusée (409).
/// </summary>
public sealed class CatalogStore
{
public const string FileName = "catalog.json";
private const string HistoryFolder = "catalog-history";
private const int HistoryLength = 20;
private static readonly JsonSerializerOptions Json = CatalogRules.PublishedJson;
private static readonly TimeSpan LockTimeout = TimeSpan.FromSeconds(5);
private const UnixFileMode PublicFileMode =
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead;
private readonly string _path;
private readonly string _lockPath;
private readonly string _historyDirectory;
private readonly string? _publicFile;
private readonly object _writeLock = new();
public CatalogStore(IOptions<LauncherApiOptions> options)
: this(options.Value.DataDirectoryFullPath, options.Value.CatalogPublicFileFullPath) { }
public CatalogStore(string dataDirectory, string? publicFile)
{
SecureFiles.EnsureDirectory(dataDirectory);
_path = Path.Combine(dataDirectory, FileName);
_lockPath = _path + ".lock";
_historyDirectory = Path.Combine(dataDirectory, HistoryFolder);
_publicFile = publicFile;
}
public bool PublishingEnabled => _publicFile is not null;
/// <summary>Catalogue actuel (vide, révision 0, s'il n'a jamais été publié).</summary>
public CatalogDocument Read()
{
if (!File.Exists(_path)) return new CatalogDocument();
var text = File.ReadAllText(_path);
var document = JsonSerializer.Deserialize<CatalogDocument>(text, Json)
?? throw new InvalidDataException($"{FileName} est vide.");
var errors = new List<string>();
return CatalogRules.Normalize(document, imageHost: null, errors) with { Revision = document.Revision };
}
/// <summary>
/// Publie un catalogue déjà contrôlé (<see cref="CatalogRules.Normalize"/>) : révision suivante,
/// fichier public, puis référence privée et historique.
/// </summary>
/// <returns>Le catalogue publié et le précédent.</returns>
public (CatalogDocument Published, CatalogDocument Previous) Publish(CatalogDocument catalog, long baseRevision, DateTimeOffset now)
{
if (_publicFile is null)
throw new ApiRejection(503, ApiErrorCodes.NotConfigured,
"Publication du catalogue non configurée sur le serveur (LauncherApi:Catalog:PublicFile).");
lock (_writeLock)
using (SecureFiles.AcquireLock(_lockPath, LockTimeout))
{
var current = Read();
if (current.Revision != baseRevision)
throw ApiRejection.Conflict(
$"Le catalogue a été publié entre-temps (révision {current.Revision}) : rechargez-le, puis refaites vos modifications.",
ApiErrorCodes.RevisionConflict);
var published = catalog with
{
Format = CatalogDocument.FormatName,
Version = CatalogDocument.CurrentVersion,
Revision = current.Revision + 1,
UpdatedAt = now
};
var json = JsonSerializer.Serialize(published, Json);
if (Encoding.UTF8.GetByteCount(json) > CatalogRules.MaxFileBytes)
throw ApiRejection.BadRequest($"Catalogue trop volumineux (au plus {CatalogRules.MaxFileBytes / 1024} Ko).");
// 1. Référence privée (+ .bak) : c'est elle qui porte la révision.
var previousText = File.Exists(_path) ? File.ReadAllText(_path) : null;
SecureFiles.WriteAllTextAtomic(_path, json, keepBackup: true);
// 2. Fichier public. En cas d'échec, la référence privée est remise dans son état précédent :
// les joueurs et le serveur restent sur la même révision (rien n'est publié à moitié).
try
{
WritePublic(json);
}
catch (IOException)
{
RestorePrivate(previousText);
throw;
}
// 3. Historique (aide uniquement).
WriteHistory(published.Revision, json);
return (published, current);
}
}
private void WritePublic(string json)
{
var directory = Path.GetDirectoryName(_publicFile!)!;
var temp = Path.Combine(directory, $".{Path.GetFileName(_publicFile)}.{Guid.NewGuid():N}.tmp");
try
{
var options = new FileStreamOptions { Mode = FileMode.CreateNew, Access = FileAccess.Write, Share = FileShare.None };
if (!OperatingSystem.IsWindows()) options.UnixCreateMode = PublicFileMode;
using (var stream = new FileStream(temp, options))
{
stream.Write(new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(json));
stream.Flush(flushToDisk: true);
}
// Le masque du service (UMask=0077) s'applique à la création : droits fixés explicitement
// pour que nginx puisse lire le fichier (lecture seule pour tous, écriture par le service seul).
if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(temp, PublicFileMode);
File.Move(temp, _publicFile!, overwrite: true); // renommage atomique dans le même dossier
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
try
{
File.Delete(temp);
}
catch (Exception cleanup) when (cleanup is IOException or UnauthorizedAccessException)
{
// fichier temporaire laissé : sans conséquence (nom unique)
}
throw new IOException($"Écriture du catalogue public impossible ({_publicFile}) : {ex.Message}", ex);
}
}
private void RestorePrivate(string? previousText)
{
try
{
if (previousText is null) File.Delete(_path);
else SecureFiles.WriteAllTextAtomic(_path, previousText, keepBackup: false);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Très improbable (le dossier privé vient d'être écrit) : la révision suivante sera alors
// refusée (409) jusqu'au rechargement de la page, sans rien écraser.
}
}
private void WriteHistory(long revision, string json)
{
try
{
SecureFiles.EnsureDirectory(_historyDirectory);
SecureFiles.WriteAllTextAtomic(Path.Combine(_historyDirectory, $"catalog-{revision:D6}.json"), json, keepBackup: false);
foreach (var old in Directory.GetFiles(_historyDirectory, "catalog-*.json").OrderByDescending(f => f, StringComparer.Ordinal).Skip(HistoryLength))
File.Delete(old);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// L'historique est une aide : son échec n'annule pas la publication.
}
}
}
@@ -0,0 +1,310 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Server.Hosting;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Storage;
/// <summary>
/// Notes de version des jeux, dans le manifest.json PUBLIÉ de chaque jeu (LauncherApi:Games:&lt;id&gt;:ManifestFile).
/// • Seule l'entrée « changelog » de la version demandée est remplacée : le reste du manifest (fichiers,
/// archive, version, exécutable…) est recopié tel quel. Numéro et date de la version sont conservés.
/// • Contrôle des modifications concurrentes : empreinte SHA-256 du manifest lu par l'éditeur ; si le
/// fichier a changé (nouvelle version publiée, autre administrateur), la modification est refusée (409).
/// L'empreinte est vérifiée une seconde fois juste avant le remplacement.
/// • Écriture atomique (fichier temporaire caché du même dossier puis renommage), droits du manifest
/// existant conservés ; l'ancienne version est copiée dans les données privées (20 dernières).
/// </summary>
public sealed class ChangelogStore
{
/// <summary>Taille maximale d'un manifest lu ou écrit (même borne que le launcher).</summary>
public const long MaxManifestBytes = 64L * 1024 * 1024;
private const string HistoryFolder = "manifest-history";
private const int HistoryLength = 20;
private static readonly TimeSpan LockTimeout = TimeSpan.FromSeconds(10);
/// <summary>Même présentation que ManifestBuilder (indenté, camelCase, accents non échappés).</summary>
private static readonly JsonSerializerOptions ManifestJson = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
MaxDepth = 64
};
private static readonly JsonDocumentOptions ReadOptions = new()
{
CommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
MaxDepth = 64
};
/// <summary>Champs d'une entrée « changelog » modifiés par l'éditeur (jamais « version » ni « date »).</summary>
private static readonly string[] EditableFields = { "title", "subtitle", "introduction", "sections", "changes" };
private readonly IReadOnlyDictionary<string, string> _manifests;
private readonly string _dataDirectory;
private readonly object _writeLock = new();
public ChangelogStore(IOptions<LauncherApiOptions> options)
: this(options.Value.GameManifestFiles, options.Value.DataDirectoryFullPath) { }
public ChangelogStore(IReadOnlyDictionary<string, string> manifests, string dataDirectory)
{
_manifests = manifests;
_dataDirectory = dataDirectory;
}
/// <summary>Notes de version publiées du jeu.</summary>
public ChangelogState Read(string gameId)
{
var (path, key) = Locate(gameId);
var bytes = ReadManifest(path);
var root = Parse(bytes, key);
return State(key, root, bytes);
}
/// <summary>
/// Remplace les notes d'UNE version existante (déjà contrôlées par <see cref="ChangelogLimits.Check"/>).
/// </summary>
/// <returns>Le nouvel état et les notes remplacées (audit).</returns>
public (ChangelogState State, ChangelogEntry Previous) Save(string gameId, string version, ChangelogEntry entry, string baseSha256)
{
var (path, key) = Locate(gameId);
lock (_writeLock)
using (SecureFiles.AcquireLock(Path.Combine(_dataDirectory, $"manifest-{key.ToLowerInvariant()}.lock"), LockTimeout))
{
var bytes = ReadManifest(path);
if (!string.Equals(Sha256(bytes), baseSha256, StringComparison.OrdinalIgnoreCase))
throw Conflict();
var root = Parse(bytes, key);
var changelog = ChangelogArray(root)
?? throw ApiRejection.NotFound($"Le manifest de « {key} » ne contient aucune note de version.");
var index = -1;
for (var i = 0; i < changelog.Count; i++)
if (changelog[i] is JsonObject candidate && string.Equals(StringValue(candidate, "version"), version, StringComparison.Ordinal))
{
index = i;
break;
}
if (index < 0)
throw ApiRejection.NotFound($"La version {version} n'a pas de notes dans le manifest publié (seules les versions existantes se modifient).");
var old = (JsonObject)changelog[index]!;
var previous = Entries(new JsonArray(old.DeepClone())).FirstOrDefault() ?? new ChangelogEntry();
// Notes nettoyées et bornées (mêmes règles que le launcher) ; liste à plat régénérée pour les
// anciens launchers ; numéro et date de la version repris du manifest.
var clean = ChangelogLimits.Normalize(new[] { entry }).FirstOrDefault()
?? throw ApiRejection.BadRequest("Les notes de version ne peuvent pas être vides.");
if (clean.Sections.Count > 0) clean.Changes = clean.ToFlatChanges();
// Entrée existante recopiée (numéro, date, propriétés inconnues de ce serveur conservés, quelle
// que soit la casse des noms), puis seuls les champs des notes sont remplacés.
var replacement = old.DeepClone().AsObject();
var edited = JsonSerializer.SerializeToNode(clean, ManifestJson)!.AsObject();
foreach (var name in EditableFields)
{
foreach (var existing in replacement.Select(p => p.Key).Where(k => string.Equals(k, name, StringComparison.OrdinalIgnoreCase)).ToList())
replacement.Remove(existing);
if (edited[name] is { } value) replacement[name] = value.DeepClone();
}
changelog[index] = replacement;
// Budget de tout l'historique : au-delà, le launcher tronquerait les versions les plus anciennes.
var total = Entries(changelog).OfType<ChangelogEntry>().Sum(ChangelogLimits.DisplayedItems);
if (total > ChangelogLimits.MaxTotalItems)
throw ApiRejection.BadRequest(
$"Historique trop long : {total} éléments pour toutes les versions ({ChangelogLimits.MaxTotalItems} au plus).");
var json = root.ToJsonString(ManifestJson);
var newBytes = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(json);
if (newBytes.LongLength > MaxManifestBytes) throw ApiRejection.BadRequest("Manifest trop volumineux.");
Backup(key, bytes);
WriteManifest(path, newBytes, baseSha256);
return (State(key, root, newBytes), previous);
}
}
// ------------------------------------------------------------------ Lecture
private (string Path, string GameId) Locate(string gameId)
{
foreach (var (key, path) in _manifests)
if (string.Equals(key, gameId, StringComparison.OrdinalIgnoreCase))
return (path, key);
throw new ApiRejection(503, ApiErrorCodes.NotConfigured,
$"Notes de version non modifiables pour « {gameId} » : manifest non configuré sur le serveur (LauncherApi:Games:{gameId}:ManifestFile).");
}
private static byte[] ReadManifest(string path)
{
var info = new FileInfo(path);
if (!info.Exists) throw ApiRejection.NotFound("Aucun manifest publié pour ce jeu (publiez d'abord une version).");
if (info.Length > MaxManifestBytes) throw new InvalidDataException("Manifest trop volumineux.");
return File.ReadAllBytes(path);
}
private static JsonObject Parse(byte[] bytes, string gameId)
{
JsonObject root;
try
{
root = JsonNode.Parse(StripBom(bytes), documentOptions: ReadOptions) as JsonObject
?? throw new InvalidDataException("le manifest n'est pas un objet JSON");
}
catch (Exception ex) when (ex is JsonException or ArgumentException)
{
throw new InvalidDataException($"Manifest illisible : {ex.Message}", ex);
}
// Garde-fou de configuration : le fichier configuré doit bien être celui de ce jeu.
var manifestGameId = StringValue(root, "gameId");
if (!string.IsNullOrEmpty(manifestGameId) && !string.Equals(manifestGameId, gameId, StringComparison.OrdinalIgnoreCase))
throw new ApiRejection(503, ApiErrorCodes.NotConfigured,
$"Le manifest configuré pour « {gameId} » est celui du jeu « {manifestGameId} » : vérifiez LauncherApi:Games sur le serveur.");
return root;
}
private static ChangelogState State(string gameId, JsonObject root, byte[] bytes)
{
var entries = ChangelogLimits.Normalize(Entries(ChangelogArray(root)));
// Liste à plat (anciens launchers) inutile à l'éditeur quand les sections existent : réponse allégée.
foreach (var entry in entries.Where(e => e.Sections.Count > 0)) entry.Changes = new List<string>();
return new ChangelogState
{
GameId = gameId,
ManifestVersion = StringValue(root, "version") ?? "",
ManifestSha256 = Sha256(bytes),
Entries = entries
};
}
/// <summary>Entrées du manifest ; une entrée illisible (date ou type invalide) rend le manifest illisible.</summary>
private static List<ChangelogEntry?> Entries(JsonArray? changelog)
{
try
{
return changelog?.Deserialize<List<ChangelogEntry?>>(ManifestJson) ?? new List<ChangelogEntry?>();
}
catch (Exception ex) when (ex is JsonException or InvalidOperationException or FormatException)
{
throw new InvalidDataException($"Notes de version illisibles dans le manifest : {ex.Message}", ex);
}
}
/// <summary>Tableau « changelog » du manifest (nom insensible à la casse, comme pour le launcher).</summary>
private static JsonArray? ChangelogArray(JsonObject root)
{
foreach (var (name, value) in root)
if (string.Equals(name, "changelog", StringComparison.OrdinalIgnoreCase))
return value as JsonArray;
return null;
}
private static string? StringValue(JsonObject node, string name)
{
foreach (var (key, value) in node)
if (string.Equals(key, name, StringComparison.OrdinalIgnoreCase))
return value is JsonValue v && v.TryGetValue<string>(out var text) ? text : null;
return null;
}
private static ReadOnlySpan<byte> StripBom(byte[] bytes) =>
bytes.AsSpan().StartsWith(new byte[] { 0xEF, 0xBB, 0xBF }) ? bytes.AsSpan(3) : bytes;
public static string Sha256(byte[] bytes) => Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
private static ApiRejection Conflict() => ApiRejection.Conflict(
"Le manifest a changé sur le serveur depuis son chargement (nouvelle version publiée ou autre administrateur) : rechargez les notes.",
ApiErrorCodes.RevisionConflict);
// ------------------------------------------------------------------ Écriture
/// <summary>Copie de l'ancien manifest dans les données privées (aide à la récupération).</summary>
private void Backup(string gameId, byte[] bytes)
{
try
{
var directory = Path.Combine(_dataDirectory, HistoryFolder, gameId.ToLowerInvariant());
SecureFiles.EnsureDirectory(Path.Combine(_dataDirectory, HistoryFolder));
SecureFiles.EnsureDirectory(directory);
var name = $"manifest-{DateTime.UtcNow:yyyyMMdd-HHmmss}-{Sha256(bytes)[..8]}.json";
SecureFiles.WriteAllTextAtomic(Path.Combine(directory, name), Encoding.UTF8.GetString(StripBom(bytes)), keepBackup: false);
foreach (var old in Directory.GetFiles(directory, "manifest-*.json").OrderByDescending(f => f, StringComparer.Ordinal).Skip(HistoryLength))
File.Delete(old);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// La copie est une aide : son échec n'empêche pas la modification.
}
}
/// <summary>
/// Remplace le manifest publié : fichier temporaire caché (refusé par nginx) dans le même dossier,
/// droits du manifest existant, empreinte revérifiée, puis renommage atomique.
/// </summary>
private static void WriteManifest(string path, byte[] bytes, string baseSha256)
{
var directory = Path.GetDirectoryName(path)!;
if (new FileInfo(path).LinkTarget is not null)
throw new ManifestWriteRefusedException("le manifest publié est un lien symbolique : le remplacer romprait le lien.");
if (!OperatingSystem.IsWindows())
{
// Le fichier remplaçant appartient au compte du service, avec le groupe du dossier SEULEMENT si
// celui-ci a le bit setgid : sinon nginx (groupe www-data) pourrait perdre l'accès au manifest.
var mode = File.GetUnixFileMode(path);
if (!mode.HasFlag(UnixFileMode.OtherRead) && mode.HasFlag(UnixFileMode.GroupRead) &&
!File.GetUnixFileMode(directory).HasFlag(UnixFileMode.SetGroup))
throw new ManifestWriteRefusedException(
"le dossier du jeu n'a pas le bit setgid (chmod g+s) : le manifest remplacé ne serait plus lisible par nginx.");
}
var temp = Path.Combine(directory, $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp");
try
{
using (var stream = new FileStream(temp, new FileStreamOptions
{
Mode = FileMode.CreateNew, Access = FileAccess.Write, Share = FileShare.None
}))
{
stream.Write(bytes);
stream.Flush(flushToDisk: true);
}
if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(temp, File.GetUnixFileMode(path));
// Dernier contrôle : un envoi (WinSCP…) pendant la préparation n'est pas écrasé.
if (!string.Equals(Sha256(File.ReadAllBytes(path)), baseSha256, StringComparison.OrdinalIgnoreCase))
throw Conflict();
File.Move(temp, path, overwrite: true);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ApiRejection)
{
try
{
File.Delete(temp);
}
catch (Exception cleanup) when (cleanup is IOException or UnauthorizedAccessException)
{
// fichier temporaire laissé : sans conséquence (nom unique, caché)
}
if (ex is ApiRejection) throw;
throw new IOException($"Écriture du manifest impossible ({path}) : {ex.Message}", ex);
}
}
}
/// <summary>Remplacement du manifest refusé par précaution (configuration du dossier à corriger) ; message sans chemin.</summary>
public sealed class ManifestWriteRefusedException : IOException
{
public ManifestWriteRefusedException(string message) : base(message) { }
}
@@ -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,46 @@
{
"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,
// Catalogue des jeux (phase 3) : fichier public écrit par le service, servi par nginx.
// Vide : publication désactivée. À renseigner dans appsettings.Production.json sur le VPS
// (voir server/deploy/LISEZMOI.md), ex. « /srv/exovival/www/catalog/catalog.json ».
"Catalog": {
"PublicFile": "",
"PublicUrl": ""
},
// Notes de version modifiables en ligne (phase 4) : manifest.json publié de chaque jeu, par
// identifiant (GameId de launcher.json). À renseigner dans appsettings.Production.json sur le VPS
// (voir server/deploy/LISEZMOI.md), ex. « "EZR": { "ManifestFile": "/srv/exovival/www/exovivalzombie/manifest.json" } ».
"Games": {},
"Limits": {
"RequestsPerMinute": 300,
"AuthPerMinute": 20,
"EnrollmentsPerHour": 10,
"MaxActiveInvitations": 20,
"MaxRequestBodyBytes": 65536
}
}
}
+303
View File
@@ -0,0 +1,303 @@
# 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.
## 3 bis. Catalogue des jeux (VPS)
La page **Jeux** du menu d'administration publie `catalog.json` (nom affiché, accroche, description,
liens, thème, images, ordre des jeux), que les launchers des joueurs lisent au démarrage à l'adresse
`catalogUrl` de `launcher.json`. Le serveur l'écrit dans **un seul** dossier, servi par nginx, séparé
de ses données privées. Exemple ci-dessous avec le site de « Serveur de téléchargement Exovival »
(`/srv/exovival/www`) : adapte le chemin à la racine réelle de ton site.
1. Crée le dossier public (propriétaire `deploy`, groupe `www-data` pour nginx) :
```
sudo install -d -m 2750 -o deploy -g www-data /srv/exovival/www/catalog /srv/exovival/www/catalog/images
```
2. Indique le fichier au serveur dans `/opt/launcher-api/appsettings.Production.json` (à fusionner
avec tes autres réglages) :
```
{ "LauncherApi": { "Catalog": { "PublicFile": "/srv/exovival/www/catalog/catalog.json" } } }
```
L'adresse publique est déduite de `PublicOrigin` (`https://launcher.lanisys.fr/catalog/catalog.json`) ;
sinon, ajoute `"PublicUrl"`. Elle doit être **identique** à `catalogUrl` dans `launcher.json`.
Le serveur refuse de démarrer si le dossier n'existe pas, ou si le dossier public et
`/var/lib/launcher-api` sont l'un dans l'autre.
3. Autorise le service à écrire dans ce dossier, et seulement lui (le reste du système reste en
lecture seule) :
```
sudo mkdir -p /etc/systemd/system/launcher-api.service.d
sudo nano /etc/systemd/system/launcher-api.service.d/catalog.conf
```
Colle le contenu de `server/deploy/launcher-api-catalog.conf` (ligne `ReadWritePaths=` adaptée),
puis :
```
sudo systemctl daemon-reload && sudo systemctl restart launcher-api
```
Ce fichier est conservé lors des mises à jour du serveur.
4. nginx : colle le bloc `location = /catalog/catalog.json { … }` de `server/deploy/nginx-catalog.conf`
dans le bloc HTTPS du site, avant `location / {`, puis `sudo nginx -t && sudo systemctl reload nginx`.
5. Publie depuis la page **Jeux**, puis vérifie depuis ton PC :
```
PS> curl.exe -sI https://launcher.lanisys.fr/catalog/catalog.json
```
Attendu : `200 OK` et `Cache-Control: no-cache, no-store, must-revalidate`. Le fichier est en
lecture seule pour tous (`-rw-r--r--`) : seul le service le modifie.
**Images** (icône, fond) : envoie-les en SFTP (compte `deploy`) dans `www/catalog/images/`, en PNG
ou JPEG de 4 Mo au plus, puis saisis leur adresse dans la page Jeux, par ex.
`https://launcher.lanisys.fr/catalog/images/fond-halloween.jpg`. Les launchers gardent chaque image
en cache : pour changer une image, envoie-la sous un **nouveau nom**. Les images d'un autre
serveur sont refusées.
Sans l'étape 2, la page Jeux reste consultable mais la publication est désactivée (bandeau orange).
## 3 ter. Notes de version modifiables en ligne (VPS)
La page **Notes de version** du menu d'administration corrige les notes des versions déjà publiées,
directement dans le `manifest.json` du jeu (rien d'autre n'y est modifié). Exemple ci-dessous avec le
dossier du jeu de « Serveur de téléchargement Exovival » (`/srv/exovival/www/exovivalzombie`) : adapte
le chemin au dossier réel, celui de `manifestUrl` dans `launcher.json`.
> **Compromis de sécurité** : le service pourra écrire dans le dossier du jeu (il remplace le manifest
> par renommage dans ce dossier). Un service compromis pourrait donc modifier les fichiers du jeu de ce
> dossier. L'autorisation se limite à ce seul dossier ; sans cette étape, rien n'est modifiable en ligne.
1. Vérifie que le dossier du jeu a le **bit setgid** (le `s` de `drwxr-s---`), pour que le manifest
remplacé garde le groupe `www-data` lu par nginx :
```
stat -c '%A %U:%G' /srv/exovival/www/exovivalzombie
sudo chmod g+s /srv/exovival/www/exovivalzombie # seulement s'il manque
```
Sans lui, le serveur refuse la modification plutôt que de rendre le manifest illisible.
2. Indique le manifest au serveur dans `/opt/launcher-api/appsettings.Production.json` (à fusionner
avec tes autres réglages ; la clé est le `gameId` de `launcher.json`) :
```
{ "LauncherApi": { "Games": { "EZR": { "ManifestFile": "/srv/exovival/www/exovivalzombie/manifest.json" } } } }
```
3. Autorise le service à écrire dans ce dossier, et seulement lui :
```
sudo nano /etc/systemd/system/launcher-api.service.d/games.conf
```
Colle le contenu de `server/deploy/launcher-api-games.conf` (ligne `ReadWritePaths=` adaptée,
une par jeu), puis :
```
sudo systemctl daemon-reload && sudo systemctl restart launcher-api
```
Ce fichier est conservé lors des mises à jour du serveur.
4. Sur ton PC, ajoute `--remote-manifest <adresse du manifest>` à ton script de publication
(déjà dans `tools\publish-example.bat`) : sans lui, la publication suivante écraserait les notes
corrigées en ligne avec celles du `manifest.json` local.
5. Teste : menu d'administration › **Notes de version**, corrige une faute, **Enregistrer**. L'ancien
manifest est copié dans `/var/lib/launcher-api/manifest-history/`.
N'enregistre pas de notes pendant l'envoi d'une nouvelle version avec WinSCP : le serveur le détecte
(empreinte du manifest) et refuse sans rien écraser ; recharge simplement la page ensuite.
## 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`).
> Depuis la phase 2, le launcher sait aussi créer la clé : menu d'administration (Ctrl+Maj+Alt+A,
> ou 5 clics rapides sur le logo) › « J'ai une invitation », **sans code** → clé seule, même dossier,
> même format. Une clé créée par `admin-key.bat` est reconnue par le launcher, et inversement.
> Le launcher peut aussi créer la clé **dans la puce de sécurité (TPM) du PC** (non copiable,
> code PIN) : il écrit alors `admin.tpmkey` (référence, sans secret) et `admin.pub` ; c'est ce
> `.pub` qu'on déclare ci-dessous. Une clé TPM ne fonctionne que dans le launcher, sur ce PC
> (`admin-key.bat` n'utilise que des clés fichier) : garde une clé fichier de secours (§ 9, `add-key`).
## 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
Depuis la phase 2, la personne invitée peut créer et enregistrer sa clé **dans le launcher** (menu
d'administration › « J'ai une invitation », code collé) : l'étape 2 ci-dessous se fait alors sans
ligne de commande, et le launcher affiche l'empreinte à te dicter. Inviter et approuver se font avec
`tools\admin-key.bat` jusqu'à la page « Administrateurs » du launcher (phase 6) :
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** : launcher › menu d'administration › « J'ai une invitation » (ou `admin-key.bat`, 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, ainsi que les compléments systemd (`launcher-api.service.d/catalog.conf` et `games.conf`) ;
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>` |
| Clé TPM supprimée dans le launcher (« Supprimer cette clé de la puce ») | Même commande `revoke-key`, avec l'identifiant affiché par le launcher |
| 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
├─ catalog.json (+ .bak) catalogue publié (référence de la révision)
├─ catalog-history/ 20 dernières versions publiées
├─ manifest-history/<jeu>/ manifests remplacés par la page Notes de version (20 derniers)
└─ data-protection/ clés internes d'ASP.NET Core (inutilisées par l'API)
/srv/exovival/www/catalog/ (exemple) dossier public servi par nginx (deploy:www-data, 2750)
├─ catalog.json catalogue lu par les launchers (écrit par le service, 644)
└─ images/ images du catalogue (envoyées en SFTP)
/etc/systemd/system/launcher-api.service
/etc/systemd/system/launcher-api.service.d/catalog.conf écriture du dossier public (conservé)
/etc/systemd/system/launcher-api.service.d/games.conf écriture du dossier de chaque jeu (conservé)
```
+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."
+14
View File
@@ -0,0 +1,14 @@
# =============================================================================
# Launcher API : autorisation d'écrire le catalogue des jeux (LISEZMOI.md, étape 3 bis).
# /etc/systemd/system/launcher-api.service.d/catalog.conf
#
# Le service est enfermé en lecture seule (ProtectSystem=strict) : ce complément l'autorise à
# écrire dans UN SEUL dossier, celui du fichier public du catalogue (LauncherApi:Catalog:PublicFile).
# Fichier séparé : il est conservé lors des mises à jour du serveur (installer-api.sh remplace
# launcher-api.service, pas ce dossier).
#
# Adapter le chemin au dossier réellement servi par nginx (exemple de la documentation ci-dessous),
# puis : sudo systemctl daemon-reload && sudo systemctl restart launcher-api
# =============================================================================
[Service]
ReadWritePaths=/srv/exovival/www/catalog
+17
View File
@@ -0,0 +1,17 @@
# =============================================================================
# Launcher API : autorisation de modifier le manifest.json des jeux (LISEZMOI.md, étape 3 ter).
# /etc/systemd/system/launcher-api.service.d/games.conf
#
# Le service est enfermé en lecture seule (ProtectSystem=strict) : ce complément l'autorise à
# écrire dans le dossier de chaque jeu configuré (LauncherApi:Games:<id>:ManifestFile), et
# seulement là. Le remplacement du manifest se fait par renommage dans ce dossier : c'est le dossier
# entier qui doit être autorisé (pas le seul fichier).
# Compromis : un service compromis pourrait alors modifier les fichiers du jeu de ce dossier.
# Sans ce complément (et sans LauncherApi:Games), les notes ne sont pas modifiables en ligne.
#
# Fichier séparé : conservé lors des mises à jour du serveur. Adapter le chemin au dossier du jeu
# réellement servi par nginx (exemple de la documentation ci-dessous ; une ligne par jeu), puis :
# sudo systemctl daemon-reload && sudo systemctl restart launcher-api
# =============================================================================
[Service]
ReadWritePaths=/srv/exovival/www/exovivalzombie
+76
View File
@@ -0,0 +1,76 @@
# =============================================================================
# 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).
# Catalogue des jeux : écriture autorisée dans son seul dossier public par le complément
# /etc/systemd/system/launcher-api.service.d/catalog.conf (launcher-api-catalog.conf, LISEZMOI.md).
# Notes de version : écriture dans le dossier de chaque jeu par le complément games.conf
# (launcher-api-games.conf, LISEZMOI.md étape 3 ter).
#
# 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;
}
+20
View File
@@ -0,0 +1,20 @@
# =============================================================================
# Catalogue des jeux (écrit par la Launcher API) — un ajout (voir LISEZMOI.md, étape 3 bis) :
#
# Dans le bloc HTTPS (port 443) du site /etc/nginx/sites-available/dl-exovival,
# AVANT « location / { … } » : (le bloc ci-dessous)
#
# Puis : sudo nginx -t && sudo systemctl reload nginx
#
# Les images du catalogue (catalog/images/*.png|jpg) sont servies par « location / » ; les
# fichiers temporaires de la Launcher API (.catalog.json.….tmp) par la règle des fichiers cachés
# (refusés).
# =============================================================================
# Catalogue : toujours relu sur le serveur (les launchers le lisent au démarrage).
location = /catalog/catalog.json {
limit_except GET { deny all; }
default_type application/json;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header X-Content-Type-Options "nosniff" always;
}
@@ -0,0 +1,94 @@
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);
// Clé publique : jamais écrasée non plus ; en cas d'échec, la clé privée qui vient d'être créée est retirée.
var publicPath = Path.ChangeExtension(path, PublicKeyExtension);
try
{
using var stream = new FileStream(publicPath, FileMode.CreateNew, FileAccess.Write, FileShare.None);
using var writer = new StreamWriter(stream);
writer.Write(AdminKeys.ToPem(key.ExportSubjectPublicKeyInfo()));
}
catch
{
try
{
File.Delete(path);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// clé chiffrée laissée en place : sans danger
}
throw;
}
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;
}
}
}
+186
View File
@@ -0,0 +1,186 @@
using System.Security.Cryptography;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Changelog;
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; }
/// <summary>Écart entre l'horloge du serveur et celle du PC (heure serveur = heure du PC + écart), mesuré à la connexion.</summary>
public TimeSpan ClockOffset => _clockOffset;
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);
}
// ------------------------------------------------------------------ Catalogue (informations modifiables des jeux)
public Task<CatalogState> GetCatalogAsync(CancellationToken ct = default) =>
GetAsync<CatalogState>(ApiRoutes.Catalog, ct);
/// <summary>Publie le catalogue (409 « revision_conflict » si quelqu'un a publié depuis <paramref name="baseRevision"/>).</summary>
public Task<CatalogState> PublishCatalogAsync(CatalogDocument catalog, long baseRevision, CancellationToken ct = default) =>
SendSignedAsync<CatalogState>(HttpMethod.Put, ApiRoutes.Catalog,
new CatalogUpdateRequest { BaseRevision = baseRevision, Catalog = catalog }, ct);
// ------------------------------------------------------------------ Notes de version (manifest.json publié)
/// <summary>Notes de version publiées d'un jeu, avec l'empreinte du manifest lu.</summary>
public Task<ChangelogState> GetChangelogAsync(string gameId, CancellationToken ct = default) =>
GetAsync<ChangelogState>(ApiRoutes.GameChangelog(CheckGameId(gameId)), ct);
/// <summary>
/// Remplace les notes d'UNE version existante (requête signée). <paramref name="baseSha256"/> : empreinte
/// du manifest sur lequel la modification a été préparée (409 revision_conflict s'il a changé).
/// </summary>
public Task<ChangelogState> SaveChangelogEntryAsync(string gameId, ChangelogEntry entry, string baseSha256, CancellationToken ct = default)
{
if (!ChangelogLimits.IsEditableVersion(entry.Version))
throw new ArgumentException($"Version « {entry.Version} » non modifiable en ligne.", nameof(entry));
return SendSignedAsync<ChangelogState>(HttpMethod.Put, ApiRoutes.GameChangelogEntry(CheckGameId(gameId), entry.Version),
new ChangelogUpdateRequest { BaseSha256 = baseSha256, Entry = entry }, ct);
}
private static string CheckGameId(string gameId) =>
CatalogRules.IsValidGameId(gameId) ? gameId : throw new ArgumentException($"Identifiant de jeu invalide : « {gameId} ».", nameof(gameId));
// ------------------------------------------------------------------ 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é) ; clé de la puce TPM dans le launcher (Platform\TpmKeyStore, phase 2b).
/// </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
};
}
+51
View File
@@ -0,0 +1,51 @@
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";
// ------------------------------------------------------------------ Catalogue (informations modifiables des jeux)
public const string Catalog = "v1/catalog";
// ------------------------------------------------------------------ Notes de version (manifest.json de chaque jeu)
public const string GameChangelogPattern = "v1/games/{gameId}/changelog";
public const string GameChangelogEntryPattern = "v1/games/{gameId}/changelog/{version}";
/// <summary>Notes de version d'un jeu (identifiant et version : caractères sûrs uniquement, rien à encoder).</summary>
public static string GameChangelog(string gameId) => $"v1/games/{gameId}/changelog";
public static string GameChangelogEntry(string gameId, string version) => $"v1/games/{gameId}/changelog/{version}";
}
+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,126 @@
namespace GameLauncher.Contracts.Catalog;
/// <summary>
/// Informations MODIFIABLES des jeux (catalog.json, publié par le serveur et lu par les launchers).
/// Les jeux restent déclarés dans launcher.json (intégré à l'exe) : une entrée pour un jeu non déclaré
/// est ignorée par le launcher. Toute valeur absente (null) = valeur de launcher.json.
/// Ce fichier est PUBLIC : il ne contient que de l'affichage (aucun nom d'administrateur).
/// </summary>
public sealed record CatalogDocument
{
public const string FormatName = "gamelauncher-catalog";
public const int CurrentVersion = 1;
public string Format { get; init; } = FormatName;
public int Version { get; init; } = CurrentVersion;
/// <summary>Numéro de version du contenu, augmenté à chaque publication (contrôle des modifications concurrentes).</summary>
public long Revision { get; init; }
public DateTimeOffset? UpdatedAt { get; init; }
public CatalogLauncher? Launcher { get; init; }
/// <summary>Par identifiant de jeu (GameId de launcher.json).</summary>
public Dictionary<string, CatalogGame> Games { get; init; } = new(StringComparer.OrdinalIgnoreCase);
}
/// <summary>Réglages d'affichage du launcher (liste des jeux).</summary>
public sealed record CatalogLauncher
{
public string? LauncherName { get; init; }
/// <summary>Jeu affiché au premier lancement.</summary>
public string? DefaultGame { get; init; }
/// <summary>Ordre des jeux dans la liste (les jeux non cités suivent, dans l'ordre de launcher.json).</summary>
public List<string>? GameOrder { get; init; }
}
/// <summary>Informations modifiables d'un jeu (null : valeur de launcher.json).</summary>
public sealed record CatalogGame
{
/// <summary>Nom AFFICHÉ (le nom de launcher.json reste celui du raccourci, du registre et des dossiers).</summary>
public string? GameName { get; init; }
public string? Tagline { get; init; }
/// <summary>Texte de présentation (plusieurs lignes, texte brut).</summary>
public string? Description { get; init; }
public string? WebsiteUrl { get; init; }
public List<CatalogLink>? Links { get; init; }
/// <summary>Thème INTÉGRÉ au launcher (« default », « exovival »…), jamais un fichier.</summary>
public string? Theme { get; init; }
/// <summary>Image https (PNG ou JPEG) du même serveur que le catalogue.</summary>
public string? Icon { get; init; }
/// <summary>Image de fond https (PNG ou JPEG) du même serveur que le catalogue.</summary>
public string? Background { get; init; }
/// <summary>0 (désactivé) ou 5 à 1440 minutes.</summary>
public int? UpdateCheckIntervalMinutes { get; init; }
/// <summary>Masqué de la liste (un jeu installé sur le PC reste toujours affiché).</summary>
public bool? Hidden { get; init; }
}
/// <summary>Lien affiché sous la présentation du jeu (Discord, YouTube…).</summary>
public sealed record CatalogLink
{
/// <summary>Voir <see cref="CatalogLinkKinds"/> (choisit l'icône intégrée).</summary>
public string Kind { get; init; } = CatalogLinkKinds.Website;
public string? Label { get; init; }
public string Url { get; init; } = "";
}
public static class CatalogLinkKinds
{
public const string Website = "website";
public const string Discord = "discord";
public const string YouTube = "youtube";
public const string X = "x";
public const string Twitch = "twitch";
public const string Steam = "steam";
public const string Other = "other";
public static readonly IReadOnlyList<string> All = new[] { Website, Discord, YouTube, X, Twitch, Steam, Other };
/// <summary>Libellé par défaut d'un lien.</summary>
public static string DefaultLabel(string kind) => kind switch
{
Website => "Site web",
Discord => "Discord",
YouTube => "YouTube",
X => "X (Twitter)",
Twitch => "Twitch",
Steam => "Steam",
_ => "Lien"
};
}
/// <summary>Publication du catalogue depuis le menu d'administration (requête signée, permission games.edit).</summary>
public sealed record CatalogUpdateRequest
{
/// <summary>Révision sur laquelle les modifications ont été faites (refus si quelqu'un a publié entre-temps).</summary>
public long BaseRevision { get; init; }
public CatalogDocument Catalog { get; init; } = new();
}
/// <summary>Catalogue tel que connu du serveur, pour le menu d'administration.</summary>
public sealed record CatalogState
{
public CatalogDocument Catalog { get; init; } = new();
/// <summary>Faux : la publication n'est pas configurée sur le serveur (LauncherApi:Catalog:PublicFile).</summary>
public bool PublishingEnabled { get; init; }
/// <summary>Adresse publique du catalogue (pour information).</summary>
public string? PublicUrl { get; init; }
}
@@ -0,0 +1,274 @@
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace GameLauncher.Contracts.Catalog;
/// <summary>
/// Contrôle et nettoyage du catalogue, communs au serveur (refus de toute valeur invalide) et au
/// launcher des joueurs (valeur invalide ignorée : la valeur de launcher.json s'applique).
/// Tout est borné : textes, nombre de jeux et de liens, adresses (https uniquement), images (même
/// serveur que le catalogue, PNG ou JPEG), thème (identifiant d'un thème intégré, jamais un fichier).
/// </summary>
public static partial class CatalogRules
{
/// <summary>
/// Taille maximale de catalog.json. Inférieure à la taille maximale d'une requête de l'API (64 Ko :
/// Limits:MaxRequestBodyBytes et client_max_body_size de nginx), qui n'est donc pas à augmenter.
/// </summary>
public const int MaxFileBytes = 48 * 1024;
/// <summary>Format du fichier publié (JSON indenté, écrit par le serveur).</summary>
public static readonly JsonSerializerOptions PublishedJson = new(ApiJson.Options) { WriteIndented = true };
/// <summary>Taille (octets) du fichier publié pour ce catalogue.</summary>
public static int PublishedSize(CatalogDocument catalog) =>
Encoding.UTF8.GetByteCount(JsonSerializer.Serialize(catalog, PublishedJson));
public const int MaxGames = 50;
public const int MaxGameNameLength = 80;
public const int MaxLauncherNameLength = 80;
public const int MaxTaglineLength = 200;
public const int MaxDescriptionLength = 2000;
public const int MaxDescriptionLines = 40;
public const int MaxLinks = 8;
public const int MaxLinkLabelLength = 40;
public const int MaxUrlLength = 500;
public const int MinUpdateInterval = 5;
public const int MaxUpdateInterval = 1440;
[GeneratedRegex(@"\A[A-Za-z0-9_.\-]{1,64}\z")]
private static partial Regex GameIdRegex();
[GeneratedRegex(@"\A[a-z0-9][a-z0-9\-]{0,31}\z")]
private static partial Regex ThemeRegex();
public static bool IsValidGameId(string? id) => id is not null && GameIdRegex().IsMatch(id);
public static bool IsValidTheme(string? theme) => theme is not null && ThemeRegex().IsMatch(theme);
/// <summary>
/// Nettoie le catalogue. Chaque valeur invalide est retirée (null) et décrite dans <paramref name="errors"/> :
/// le serveur refuse la publication s'il y en a, le launcher ignore simplement ces valeurs.
/// </summary>
/// <param name="imageHost">Hôte autorisé pour les images (celui du catalogue) ; null : https suffit.</param>
public static CatalogDocument Normalize(CatalogDocument input, string? imageHost, List<string> errors)
{
if (input.Format != CatalogDocument.FormatName) errors.Add("Format de catalogue inconnu.");
if (input.Version != CatalogDocument.CurrentVersion) errors.Add($"Version de catalogue non prise en charge ({input.Version}).");
if (input.Revision < 0) errors.Add("Révision invalide.");
var games = new Dictionary<string, CatalogGame>(StringComparer.OrdinalIgnoreCase);
foreach (var (id, game) in input.Games ?? new Dictionary<string, CatalogGame>())
{
if (!IsValidGameId(id))
{
errors.Add($"Identifiant de jeu invalide : « {Truncate(id)} ».");
continue;
}
if (games.ContainsKey(id))
{
errors.Add($"Jeu « {id} » présent plusieurs fois.");
continue;
}
if (games.Count >= MaxGames)
{
errors.Add($"Trop de jeux (au plus {MaxGames}).");
break;
}
if (game is null) continue;
var cleaned = NormalizeGame(id, game, imageHost, errors);
if (!IsEmpty(cleaned)) games[id] = cleaned;
}
return new CatalogDocument
{
Revision = Math.Max(0, input.Revision),
UpdatedAt = input.UpdatedAt,
Launcher = NormalizeLauncher(input.Launcher, errors),
Games = games
};
}
private static CatalogLauncher? NormalizeLauncher(CatalogLauncher? launcher, List<string> errors)
{
if (launcher is null) return null;
var name = Line(launcher.LauncherName, MaxLauncherNameLength, "Nom du launcher", errors);
string? defaultGame = null;
if (!string.IsNullOrWhiteSpace(launcher.DefaultGame))
{
if (IsValidGameId(launcher.DefaultGame.Trim())) defaultGame = launcher.DefaultGame.Trim();
else errors.Add("Jeu par défaut : identifiant invalide.");
}
List<string>? order = null;
if (launcher.GameOrder is { Count: > 0 })
{
order = new List<string>();
foreach (var id in launcher.GameOrder)
{
if (!IsValidGameId(id))
{
errors.Add($"Ordre des jeux : identifiant invalide « {Truncate(id)} ».");
continue;
}
if (order.Count >= MaxGames)
{
errors.Add($"Ordre des jeux : au plus {MaxGames} jeux.");
break;
}
if (!order.Contains(id, StringComparer.OrdinalIgnoreCase)) order.Add(id);
}
if (order.Count == 0) order = null;
}
return name is null && defaultGame is null && order is null
? null
: new CatalogLauncher { LauncherName = name, DefaultGame = defaultGame, GameOrder = order };
}
private static CatalogGame NormalizeGame(string id, CatalogGame game, string? imageHost, List<string> errors)
{
var prefix = $"Jeu « {id} »";
int? interval = null;
if (game.UpdateCheckIntervalMinutes is { } minutes)
{
if (minutes == 0 || minutes is >= MinUpdateInterval and <= MaxUpdateInterval) interval = minutes;
else errors.Add($"{prefix} : intervalle de recherche des mises à jour invalide (0, ou {MinUpdateInterval} à {MaxUpdateInterval} minutes).");
}
string? theme = null;
if (!string.IsNullOrWhiteSpace(game.Theme))
{
var value = game.Theme.Trim();
if (IsValidTheme(value)) theme = value;
else errors.Add($"{prefix} : thème invalide (identifiant d'un thème intégré au launcher).");
}
return new CatalogGame
{
GameName = Line(game.GameName, MaxGameNameLength, $"{prefix} : nom", errors),
Tagline = Line(game.Tagline, MaxTaglineLength, $"{prefix} : accroche", errors),
Description = Text(game.Description, $"{prefix} : présentation", errors),
WebsiteUrl = Url(game.WebsiteUrl, $"{prefix} : site web", errors),
Links = Links(game.Links, prefix, errors),
Theme = theme,
Icon = Image(game.Icon, imageHost, $"{prefix} : icône", errors),
Background = Image(game.Background, imageHost, $"{prefix} : image de fond", errors),
UpdateCheckIntervalMinutes = interval,
Hidden = game.Hidden == true ? true : null
};
}
private static List<CatalogLink>? Links(List<CatalogLink>? links, string prefix, List<string> errors)
{
if (links is not { Count: > 0 }) return null;
var result = new List<CatalogLink>();
foreach (var link in links)
{
if (link is null) continue;
if (result.Count >= MaxLinks)
{
errors.Add($"{prefix} : au plus {MaxLinks} liens.");
break;
}
var kind = CatalogLinkKinds.All.Contains(link.Kind) ? link.Kind : null;
if (kind is null)
{
errors.Add($"{prefix} : type de lien inconnu « {Truncate(link.Kind)} ».");
continue;
}
var url = Url(link.Url, $"{prefix} : lien {CatalogLinkKinds.DefaultLabel(kind)}", errors);
if (url is null)
{
if (string.IsNullOrWhiteSpace(link.Url)) errors.Add($"{prefix} : lien {CatalogLinkKinds.DefaultLabel(kind)} sans adresse.");
continue;
}
var label = Line(link.Label, MaxLinkLabelLength, $"{prefix} : libellé de lien", errors);
result.Add(new CatalogLink { Kind = kind, Label = label, Url = url });
}
return result.Count == 0 ? null : result;
}
// ------------------------------------------------------------------ Valeurs
private static string? Line(string? value, int maxLength, string field, List<string> errors)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var cleaned = ApiText.CleanLine(value, maxLength);
if (cleaned is null) errors.Add($"{field} : {maxLength} caractères au plus, sans caractère spécial invisible.");
return cleaned;
}
/// <summary>Texte de plusieurs lignes : fins de ligne normalisées, sans caractère de contrôle ou invisible.</summary>
private static string? Text(string? value, string field, List<string> errors)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var lines = value.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n');
var sb = new StringBuilder(value.Length);
for (var i = 0; i < lines.Length; i++)
{
if (i > 0) sb.Append('\n');
var line = lines[i].TrimEnd();
if (line.Length == 0) continue;
// Indentation conservée (espaces de tête), le reste nettoyé comme une ligne simple.
var indent = line.Length - line.TrimStart(' ').Length;
var cleaned = ApiText.CleanLine(line, MaxDescriptionLength);
if (cleaned is null)
{
errors.Add($"{field} : caractère spécial invisible ou de contrôle interdit.");
return null;
}
sb.Append(' ', Math.Min(indent, 8)).Append(cleaned);
}
var text = sb.ToString().Trim('\n');
if (text.Length > MaxDescriptionLength || text.Count(c => c == '\n') + 1 > MaxDescriptionLines)
{
errors.Add($"{field} : {MaxDescriptionLength} caractères et {MaxDescriptionLines} lignes au plus.");
return null;
}
return text.Length == 0 ? null : text;
}
/// <summary>Adresse https absolue, sans identifiants, bornée.</summary>
public static string? Url(string? value, string field, List<string> errors)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var text = value.Trim();
if (text.Length > MaxUrlLength || !Uri.TryCreate(text, UriKind.Absolute, out var uri) ||
uri.Scheme != Uri.UriSchemeHttps || !string.IsNullOrEmpty(uri.UserInfo) || string.IsNullOrEmpty(uri.Host) ||
text.Any(char.IsWhiteSpace) || text.Any(char.IsControl))
{
errors.Add($"{field} : adresse https:// complète attendue ({MaxUrlLength} caractères au plus).");
return null;
}
return uri.AbsoluteUri;
}
/// <summary>Image https PNG ou JPEG, sur l'hôte du catalogue.</summary>
private static string? Image(string? value, string? imageHost, string field, List<string> errors)
{
var url = Url(value, field, errors);
if (url is null) return null;
var uri = new Uri(url);
var path = uri.AbsolutePath;
if (!(path.EndsWith(".png", StringComparison.OrdinalIgnoreCase) || path.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) ||
path.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase)) || !string.IsNullOrEmpty(uri.Query))
{
errors.Add($"{field} : image .png ou .jpg attendue (sans paramètres).");
return null;
}
if (imageHost is not null && !string.Equals(uri.Host, imageHost, StringComparison.OrdinalIgnoreCase))
{
errors.Add($"{field} : l'image doit être sur le serveur du catalogue ({imageHost}).");
return null;
}
return url;
}
private static bool IsEmpty(CatalogGame game) =>
game is { GameName: null, Tagline: null, Description: null, WebsiteUrl: null, Links: null, Theme: null, Icon: null,
Background: null, UpdateCheckIntervalMinutes: null, Hidden: null };
private static string Truncate(string? value) =>
value is null ? "" : value.Length <= 40 ? value : value[..40] + "…";
}
@@ -1,11 +1,14 @@
using System.Globalization;
using System.Text;
using System.Text.Json.Serialization;
using GameLauncher.Core.Utils;
using System.Text.RegularExpressions;
namespace GameLauncher.Core.Models;
namespace GameLauncher.Contracts.Changelog;
/// <summary>
/// Notes d'une version du jeu (manifest.json → "changelog").
/// Partagé par le launcher (affichage), ManifestBuilder (notes.txt), le menu d'administration
/// (éditeur) et la Launcher API (contrôle avant d'écrire le manifest) : mêmes champs, mêmes bornes.
///
/// Format enrichi (launcher 1.2+) : titre, sous-titre, introduction, puis des sections
/// (« PROGRESSION », « ÉNIGMES »…) contenant des modifications, des commentaires,
@@ -31,8 +34,10 @@ public sealed class ChangelogEntry
/// <summary>Format historique : une modification par ligne (compatibilité avec les anciens launchers).</summary>
public List<string> Changes { get; set; } = new();
private static readonly CultureInfo French = CultureInfo.GetCultureInfo("fr-FR");
[JsonIgnore]
public string DateText => Date == default ? "" : Date.ToLocalTime().ToString("d MMMM yyyy", Format.Culture);
public string DateText => Date == default ? "" : Date.ToLocalTime().ToString("d MMMM yyyy", French);
[JsonIgnore]
public bool HasSubtitle => !string.IsNullOrWhiteSpace(Subtitle);
@@ -142,6 +147,69 @@ public static class ChangelogLimits
public const int MaxTitleLength = 200;
public const int MaxTagLength = 24;
public const int MaxTextLength = 2000;
public const int MaxVersionLength = 64;
/// <summary>
/// Taille maximale d'une modification envoyée à la Launcher API (UNE version à la fois) : sous la
/// limite de 64 Ko des requêtes (Limits:MaxRequestBodyBytes et nginx), qui n'est pas à augmenter.
/// </summary>
public const int MaxEditRequestBytes = 60 * 1024;
private static readonly Regex VersionRegex = new(@"\A[A-Za-z0-9._\-]{1,64}\z", RegexOptions.CultureInvariant);
/// <summary>Numéro de version modifiable en ligne (lettres, chiffres, « . », « _ », « - ») : il figure dans l'adresse de l'API.</summary>
public static bool IsEditableVersion(string? version) => version is not null && VersionRegex.IsMatch(version);
/// <summary>
/// Limites dépassées par une version, sans rien tronquer (contrairement à <see cref="Normalize"/>) :
/// messages affichés par l'éditeur du menu d'administration, refus (400) par le serveur.
/// </summary>
public static List<string> Check(ChangelogEntry entry)
{
var errors = new List<string>();
void Length(string? value, int max, string what)
{
if (value is not null && value.Trim().Length > max) errors.Add($"{what} : {max} caractères au plus.");
}
Length(entry.Title, MaxTitleLength, "Titre");
Length(entry.Subtitle, MaxTitleLength * 2, "Sous-titre");
var introduction = (entry.Introduction ?? new List<string>()).OfType<string>().ToList();
if (introduction.Count > MaxIntroductionParagraphs)
errors.Add($"Introduction : {MaxIntroductionParagraphs} paragraphes au plus.");
foreach (var paragraph in introduction) Length(paragraph, MaxTextLength, "Introduction (paragraphe)");
var sections = (entry.Sections ?? new List<ChangelogSection>()).OfType<ChangelogSection>().ToList();
if (sections.Count > MaxSectionsPerEntry) errors.Add($"{MaxSectionsPerEntry} sections au plus.");
var items = 0;
for (var s = 0; s < sections.Count; s++)
{
var section = sections[s];
var where = $"Section {s + 1}";
Length(section.Title, MaxTitleLength, $"{where} (titre)");
var sectionItems = (section.Items ?? new List<ChangelogItem>()).OfType<ChangelogItem>().ToList();
if (sectionItems.Count > MaxItemsPerSection) errors.Add($"{where} : {MaxItemsPerSection} éléments au plus.");
items += sectionItems.Count;
for (var i = 0; i < sectionItems.Count; i++)
{
var item = sectionItems[i];
var at = $"{where}, élément {i + 1}";
Length(item.Text, MaxTextLength, at);
Length(item.Tag, MaxTagLength, $"{at} (étiquette)");
Length(item.Note, MaxTextLength, $"{at} (note)");
var details = (item.Details ?? new List<string>()).OfType<string>().ToList();
if (details.Count > MaxDetailsPerItem) errors.Add($"{at} : {MaxDetailsPerItem} sous-points au plus.");
foreach (var detail in details) Length(detail, MaxTextLength, $"{at} (sous-point)");
}
}
if (items > MaxItemsPerEntry) errors.Add($"{MaxItemsPerEntry} éléments au plus par version ({items} actuellement).");
if (sections.Count == 0 && (entry.Changes?.Count ?? 0) > MaxFlatChanges)
errors.Add($"{MaxFlatChanges} modifications au plus.");
return errors.Distinct().ToList();
}
/// <summary>Nombre d'éléments affichés (budget <see cref="MaxTotalItems"/> de tout l'historique).</summary>
public static int DisplayedItems(ChangelogEntry entry) => entry.DisplaySections.Sum(s => s.Items.Count);
public static List<ChangelogEntry> Normalize(IEnumerable<ChangelogEntry?>? entries)
{
@@ -229,7 +297,8 @@ public static class ChangelogLimits
var text = sb.ToString().Trim();
if (text.Length == 0) return null;
if (text.Length > maxLength) text = text[..maxLength].TrimEnd() + "…";
// « … » compris dans la longueur maximale : un texte tronqué reste dans les limites (Check).
if (text.Length > maxLength) text = text[..Math.Max(0, maxLength - 1)].TrimEnd() + "…";
return text;
}
@@ -0,0 +1,34 @@
namespace GameLauncher.Contracts.Changelog;
/// <summary>
/// Notes de version d'un jeu telles que publiées dans son manifest.json (sur le serveur), pour
/// l'éditeur du menu d'administration.
/// </summary>
public sealed record ChangelogState
{
public string GameId { get; init; } = "";
/// <summary>Version du jeu actuellement publiée (champ « version » du manifest).</summary>
public string ManifestVersion { get; init; } = "";
/// <summary>
/// Empreinte SHA-256 du manifest lu, à renvoyer avec une modification : si le manifest a changé
/// entre-temps (nouvelle version publiée, autre administrateur), la modification est refusée (409).
/// </summary>
public string ManifestSha256 { get; init; } = "";
/// <summary>Notes de chaque version, de la plus récente à la plus ancienne (bornées par <see cref="ChangelogLimits"/>).</summary>
public List<ChangelogEntry> Entries { get; init; } = new();
}
/// <summary>
/// Modification des notes d'UNE version existante. Le serveur ne change que cette entrée du manifest
/// (numéro et date de version conservés) : fichiers, archive, version et exécutable restent identiques.
/// </summary>
public sealed record ChangelogUpdateRequest
{
/// <summary>Empreinte du manifest sur lequel la modification a été préparée (<see cref="ChangelogState.ManifestSha256"/>).</summary>
public string BaseSha256 { get; init; } = "";
public ChangelogEntry? Entry { get; init; }
}
@@ -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,62 @@
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";
/// <summary>Quelqu'un a modifié la même donnée entre-temps (révision dépassée) : recharger avant de recommencer.</summary>
public const string RevisionConflict = "revision_conflict";
/// <summary>Fonction non configurée sur le serveur.</summary>
public const string NotConfigured = "not_configured";
}
@@ -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);
}
@@ -0,0 +1,87 @@
using System.Runtime.CompilerServices;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Core.Models;
using GameLauncher.Core.Utils;
namespace GameLauncher.Core.Catalog;
/// <summary>
/// Applique le catalogue aux jeux déclarés dans launcher.json — affichage uniquement :
/// • jeux inconnus de launcher.json : ignorés (le serveur ne peut ni ajouter ni retirer un jeu) ;
/// • nom AFFICHÉ seulement (raccourci, registre et dossier des données gardent le nom de launcher.json) ;
/// • thème : seulement un thème intégré au launcher ;
/// • images : seulement des fichiers du cache local (téléchargés et contrôlés par <see cref="CatalogService"/>) ;
/// • jeu masqué : toujours affiché s'il est installé sur le PC ou demandé (raccourci, désinstallation),
/// et au moins un jeu reste affiché.
/// Peut être appliqué plusieurs fois (cache au démarrage, puis version à jour) : les valeurs de
/// launcher.json sont rétablies avant chaque application.
/// </summary>
public static class CatalogMerger
{
/// <summary>Valeurs de launcher.json avant toute application du catalogue.</summary>
private sealed record Baseline(string Tagline, string? WebsiteUrl, string Theme, string? Icon, string? Background, int UpdateCheckIntervalMinutes);
private static readonly ConditionalWeakTable<LauncherConfig, Baseline> Baselines = new();
/// <param name="builtInThemes">Thèmes intégrés au launcher (les autres valeurs sont ignorées).</param>
/// <param name="localImage">Fichier local d'une image du catalogue (null : pas encore en cache → image par défaut).</param>
/// <param name="mustStayVisible">Jeu installé sur ce PC, ou demandé en ligne de commande : jamais masqué.</param>
public static void Apply(LauncherCatalog launcher, CatalogDocument catalog, IReadOnlyCollection<string> builtInThemes,
Func<string?, string?> localImage, Func<LauncherConfig, bool> mustStayVisible)
{
var declared = launcher.DeclaredGames;
foreach (var game in declared)
{
Restore(game);
if (!catalog.Games.TryGetValue(game.GameId, out var entry)) continue;
if (entry.GameName is { } name) game.DisplayName = name;
if (entry.Tagline is { } tagline) game.Tagline = tagline;
if (entry.Description is { } description) game.Description = description;
if (entry.WebsiteUrl is { } website) game.WebsiteUrl = website;
if (entry.Links is { Count: > 0 } links)
game.Links = links.Select(l => new GameLink(l.Kind, l.Label ?? CatalogLinkKinds.DefaultLabel(l.Kind), l.Url)).ToList();
if (entry.Theme is { } theme)
{
if (builtInThemes.Contains(theme, StringComparer.OrdinalIgnoreCase)) game.Theme = theme;
else Log.Warn($"Catalogue : thème « {theme} » inconnu de cette version du launcher ({game.GameId}), ignoré.");
}
if (localImage(entry.Icon) is { } icon) game.Icon = icon;
if (localImage(entry.Background) is { } background) game.Background = background;
if (entry.UpdateCheckIntervalMinutes is { } interval) game.UpdateCheckIntervalMinutes = interval;
}
// Ordre : celui du catalogue, puis les autres jeux dans l'ordre de launcher.json.
var ordered = new List<LauncherConfig>();
foreach (var id in catalog.Launcher?.GameOrder ?? new List<string>())
if (declared.FirstOrDefault(g => string.Equals(g.GameId, id, StringComparison.OrdinalIgnoreCase)) is { } game && !ordered.Contains(game))
ordered.Add(game);
ordered.AddRange(declared.Where(g => !ordered.Contains(g)));
// Visibilité.
var visible = ordered.Where(g => !(catalog.Games.TryGetValue(g.GameId, out var entry) && entry.Hidden == true) || mustStayVisible(g))
.ToList();
if (visible.Count == 0) visible.Add(ordered[0]);
var launcherName = catalog.Launcher?.LauncherName ?? launcher.DeclaredLauncherName;
var defaultGame = catalog.Launcher?.DefaultGame is { } requested && visible.Any(g => string.Equals(g.GameId, requested, StringComparison.OrdinalIgnoreCase))
? requested
: launcher.DeclaredDefaultGameId;
launcher.ApplyDisplay(launcherName, defaultGame, visible);
}
/// <summary>Rétablit les valeurs de launcher.json (enregistrées à la première application).</summary>
private static void Restore(LauncherConfig game)
{
var baseline = Baselines.GetValue(game, g => new Baseline(g.Tagline, g.WebsiteUrl, g.Theme, g.Icon, g.Background, g.UpdateCheckIntervalMinutes));
game.DisplayName = null;
game.Description = null;
game.Links = Array.Empty<GameLink>();
game.Tagline = baseline.Tagline;
game.WebsiteUrl = baseline.WebsiteUrl;
game.Theme = baseline.Theme;
game.Icon = baseline.Icon;
game.Background = baseline.Background;
game.UpdateCheckIntervalMinutes = baseline.UpdateCheckIntervalMinutes;
}
}
@@ -0,0 +1,190 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Core.Services;
using GameLauncher.Core.Utils;
namespace GameLauncher.Core.Catalog;
/// <summary>
/// Catalogue des jeux côté joueur : catalog.json (statique, publié depuis le menu d'administration)
/// téléchargé, contrôlé et borné (<see cref="CatalogRules"/> : une valeur invalide est ignorée), puis
/// mis en cache dans le dossier des données du launcher. Sans réseau : dernier cache, sinon les
/// valeurs de launcher.json. Images (icône, fond) : PNG ou JPEG du serveur du catalogue, 4 Mo au plus,
/// téléchargées une fois puis conservées en cache.
/// </summary>
public sealed class CatalogService
{
public const long MaxImageBytes = 4 * 1024 * 1024;
private const string FolderName = "catalog";
private readonly Func<Uri, long, CancellationToken, Task<byte[]>> _download;
private readonly Uri _catalogUrl;
/// <summary>Téléchargements d'images en cours (une même image n'est téléchargée qu'une fois à la fois).</summary>
private readonly ConcurrentDictionary<string, Lazy<Task<string?>>> _imageDownloads = new(StringComparer.OrdinalIgnoreCase);
public CatalogService(HttpDownloader http, string dataDirectory, Uri catalogUrl)
: this(http.GetBytesAsync, dataDirectory, catalogUrl) { }
/// <param name="download">Téléchargement borné (tests : téléchargement simulé).</param>
public CatalogService(Func<Uri, long, CancellationToken, Task<byte[]>> download, string dataDirectory, Uri catalogUrl)
{
_download = download;
_catalogUrl = catalogUrl;
Directory = Path.Combine(dataDirectory, FolderName);
CachePath = Path.Combine(Directory, "catalog.json");
ImagesDirectory = Path.Combine(Directory, "images");
}
public string Directory { get; }
public string CachePath { get; }
public string ImagesDirectory { get; }
/// <summary>Catalogue du cache (null : absent ou illisible).</summary>
public CatalogDocument? LoadCached()
{
try
{
if (!File.Exists(CachePath) || new FileInfo(CachePath).Length > CatalogRules.MaxFileBytes) return null;
return Parse(File.ReadAllBytes(CachePath), out _);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or InvalidDataException)
{
Log.Warn($"Catalogue en cache illisible : {ex.Message}");
return null;
}
}
/// <summary>
/// Télécharge le catalogue, le contrôle et le met en cache. Null si le serveur ne répond pas ou si
/// le fichier est inutilisable (le cache est alors conservé tel quel).
/// </summary>
public async Task<CatalogDocument?> FetchAsync(CancellationToken ct = default)
{
try
{
var bytes = await _download(_catalogUrl, CatalogRules.MaxFileBytes, ct).ConfigureAwait(false);
var catalog = Parse(bytes, out var errors);
if (catalog is null) return null;
foreach (var error in errors.Take(10)) Log.Warn($"Catalogue : valeur ignorée — {error}");
System.IO.Directory.CreateDirectory(Directory);
FileUtil.WriteAllTextAtomic(CachePath, JsonSerializer.Serialize(catalog, ApiJson.Options));
return catalog;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (Exception ex) when (ex is HttpRequestException or TimeoutException or IOException or UnauthorizedAccessException or
InvalidDataException or JsonException or OperationCanceledException)
{
Log.Warn($"Catalogue des jeux indisponible ({_catalogUrl.Host}) : {ex.Message}");
return null;
}
}
/// <summary>Lecture + contrôle (valeurs invalides retirées). Null si le format est inconnu.</summary>
private CatalogDocument? Parse(byte[] bytes, out List<string> errors)
{
errors = new List<string>();
var raw = JsonSerializer.Deserialize<CatalogDocument>(Encoding.UTF8.GetString(bytes).TrimStart(''), ApiJson.Options)
?? throw new InvalidDataException("catalogue vide");
if (raw.Format != CatalogDocument.FormatName || raw.Version != CatalogDocument.CurrentVersion)
{
Log.Warn($"Catalogue ignoré : format « {raw.Format} » version {raw.Version} non pris en charge par cette version du launcher.");
return null;
}
return CatalogRules.Normalize(raw, _catalogUrl.Host, errors);
}
// ------------------------------------------------------------------ Images
/// <summary>Image déjà en cache (null sinon) : utilisable immédiatement, même hors ligne.</summary>
public string? CachedImage(string? url)
{
if (ImagePath(url) is not { } path) return null;
return File.Exists(path) ? path : null;
}
/// <summary>
/// Image en cache, sinon téléchargée (PNG ou JPEG contrôlé). Null si indisponible. Appels simultanés
/// pour la même image (démarrage, puis arrière-plan) : un seul téléchargement, partagé.
/// </summary>
public Task<string?> GetImageAsync(string? url)
{
if (ImagePath(url) is not { } path) return Task.FromResult<string?>(null);
if (File.Exists(path)) return Task.FromResult<string?>(path);
var download = _imageDownloads.GetOrAdd(path, _ => new Lazy<Task<string?>>(() => DownloadImageAsync(url!, path)));
return download.Value;
}
private async Task<string?> DownloadImageAsync(string url, string path)
{
var temp = $"{path}.{Guid.NewGuid():N}.tmp";
try
{
var bytes = await _download(new Uri(url), MaxImageBytes, CancellationToken.None).ConfigureAwait(false);
if (!IsPngOrJpeg(bytes)) throw new InvalidDataException("ce n'est pas une image PNG ou JPEG");
System.IO.Directory.CreateDirectory(ImagesDirectory);
await File.WriteAllBytesAsync(temp, bytes).ConfigureAwait(false);
File.Move(temp, path, overwrite: true);
return path;
}
catch (Exception ex) when (ex is HttpRequestException or TimeoutException or IOException or UnauthorizedAccessException or
InvalidDataException or OperationCanceledException)
{
FileUtil.TryDeleteFile(temp);
Log.Warn($"Image du catalogue indisponible ({url}) : {ex.Message}");
return null;
}
finally
{
_imageDownloads.TryRemove(path, out _);
}
}
/// <summary>Supprime les images du cache qui ne sont plus utilisées par le catalogue.</summary>
public void PruneImages(CatalogDocument catalog)
{
try
{
if (!System.IO.Directory.Exists(ImagesDirectory)) return;
var used = catalog.Games.Values.SelectMany(g => new[] { g.Icon, g.Background })
.Select(ImagePath).Where(p => p is not null).ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var file in System.IO.Directory.GetFiles(ImagesDirectory))
{
if (used.Contains(file)) continue;
// Fichier temporaire : peut-être un téléchargement en cours (supprimé s'il est ancien).
if (file.EndsWith(".tmp", StringComparison.OrdinalIgnoreCase) &&
DateTime.UtcNow - File.GetLastWriteTimeUtc(file) < TimeSpan.FromHours(1))
continue;
FileUtil.TryDeleteFile(file);
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
Log.Warn($"Nettoyage des images du catalogue : {ex.Message}");
}
}
/// <summary>Fichier du cache pour une image (nom = empreinte de l'adresse) ; null si l'adresse est refusée.</summary>
private string? ImagePath(string? url)
{
if (string.IsNullOrWhiteSpace(url) || !Uri.TryCreate(url, UriKind.Absolute, out var uri) ||
!string.Equals(uri.Host, _catalogUrl.Host, StringComparison.OrdinalIgnoreCase) ||
!(uri.Scheme == Uri.UriSchemeHttps || (uri.Scheme == Uri.UriSchemeHttp && uri.IsLoopback)))
return null;
var extension = uri.AbsolutePath.EndsWith(".png", StringComparison.OrdinalIgnoreCase) ? ".png" : ".jpg";
var name = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(uri.AbsoluteUri)))[..32].ToLowerInvariant();
return Path.Combine(ImagesDirectory, name + extension);
}
internal static bool IsPngOrJpeg(ReadOnlySpan<byte> data) =>
data.StartsWith(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }) ||
data.StartsWith(new byte[] { 0xFF, 0xD8, 0xFF });
}
@@ -13,4 +13,9 @@
<Platforms>AnyCPU;x64</Platforms>
</PropertyGroup>
<ItemGroup>
<!-- Catalogue des jeux (catalog.json) : format et règles partagés avec la Launcher API. -->
<ProjectReference Include="..\GameLauncher.Contracts\GameLauncher.Contracts.csproj" />
</ItemGroup>
</Project>
@@ -1,5 +1,6 @@
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Core.Utils;
namespace GameLauncher.Core.Models;
+249 -12
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", "apiUrl", "catalogUrl" };
/// <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)
@@ -25,21 +34,34 @@ public sealed class LauncherCatalog
Publisher = publisher;
DefaultGameId = defaultGameId;
Games = games;
DeclaredGames = games;
DeclaredLauncherName = launcherName;
DeclaredDefaultGameId = defaultGameId;
IsMultiGameFormat = isMultiGameFormat;
}
/// <summary>Identifiant technique du launcher (dossier de données, instance unique).</summary>
public string LauncherId { get; }
/// <summary>Nom affiché dans la barre de titre lorsque plusieurs jeux sont proposés.</summary>
public string LauncherName { get; }
/// <summary>Nom affiché dans la barre de titre lorsque plusieurs jeux sont proposés (modifiable par le catalogue).</summary>
public string LauncherName { get; private set; }
public string Publisher { get; }
/// <summary>Jeu sélectionné au premier lancement (sinon le premier de la liste).</summary>
public string? DefaultGameId { get; }
public string? DefaultGameId { get; private set; }
public IReadOnlyList<LauncherConfig> Games { get; }
/// <summary>Jeux affichés, dans l'ordre (le catalogue peut changer l'ordre et masquer des jeux non installés).</summary>
public IReadOnlyList<LauncherConfig> Games { get; private set; }
/// <summary>Tous les jeux déclarés dans launcher.json, y compris ceux masqués par le catalogue.</summary>
public IReadOnlyList<LauncherConfig> DeclaredGames { get; }
/// <summary>Nom du launcher selon launcher.json (avant le catalogue).</summary>
public string DeclaredLauncherName { get; }
/// <summary>Jeu par défaut selon launcher.json (avant le catalogue).</summary>
public string? DeclaredDefaultGameId { get; }
/// <summary>Vrai si launcher.json utilise le tableau "games" (même avec un seul jeu).</summary>
public bool IsMultiGameFormat { get; }
@@ -54,11 +76,169 @@ 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.
/// Adresse de la Launcher API (menu d'administration), ex. « https://launcher.lanisys.fr/api/ ».
/// Facultatif : sans cette valeur, le menu d'administration est désactivé. Le launcher publié ne
/// lit cette valeur que dans le launcher.json INTÉGRÉ à l'exe (voir App) : un fichier posé à côté
/// ne peut pas rediriger les administrateurs vers un autre serveur.
/// Une valeur invalide ne bloque jamais le launcher des joueurs : elle est ignorée
/// (<see cref="ApiUrl"/> null) et expliquée dans <see cref="ApiUrlError"/>.
/// </summary>
public string DataDirectory =>
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), Publisher, LauncherId);
public string? ApiUrl { get; private set; }
/// <summary>Raison pour laquelle « apiUrl » a été ignorée (valeur invalide), sinon null.</summary>
public string? ApiUrlError { get; private set; }
/// <summary>
/// Adresse du catalogue des jeux (catalog.json : informations modifiables publiées depuis le menu
/// d'administration), ex. « https://launcher.lanisys.fr/catalog/catalog.json ». Facultatif. Comme
/// <see cref="ApiUrl"/>, le launcher publié ne la lit que dans le launcher.json intégré à l'exe.
/// </summary>
public string? CatalogUrl { get; private set; }
/// <summary>Raison pour laquelle « catalogUrl » a été ignorée (valeur invalide), sinon null.</summary>
public string? CatalogUrlError { get; private set; }
/// <summary>Affichage modifié par le catalogue (voir Catalog.CatalogMerger).</summary>
internal void ApplyDisplay(string launcherName, string? defaultGameId, IReadOnlyList<LauncherConfig> games)
{
LauncherName = launcherName;
DefaultGameId = defaultGameId;
Games = games;
}
/// <summary>
/// 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 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 =>
@@ -66,10 +246,11 @@ public sealed class LauncherCatalog
? $@"Local\{Publisher}.{Games[0].GameId}.Launcher"
: $@"Local\{Publisher}.{LauncherId}";
/// <summary>Jeu déclaré dans launcher.json (même s'il est masqué par le catalogue).</summary>
public LauncherConfig? Find(string? gameId) =>
string.IsNullOrWhiteSpace(gameId)
? null
: Games.FirstOrDefault(g => string.Equals(g.GameId, gameId, StringComparison.OrdinalIgnoreCase));
: DeclaredGames.FirstOrDefault(g => string.Equals(g.GameId, gameId, StringComparison.OrdinalIgnoreCase));
public static LauncherCatalog Load(string path)
{
@@ -103,7 +284,12 @@ 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),
ApiUrl = ReadApiUrl(root, out var singleApiUrlError),
ApiUrlError = singleApiUrlError,
CatalogUrl = ReadTrustedUrl(root, "catalogUrl", "https://launcher.exemple.fr/catalog/catalog.json", out var singleCatalogError),
CatalogUrlError = singleCatalogError,
DataFolderName = ReadDataFolder(root)
};
}
@@ -153,10 +339,25 @@ 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),
ApiUrl = ReadApiUrl(root, out var apiUrlError),
ApiUrlError = apiUrlError,
CatalogUrl = ReadTrustedUrl(root, "catalogUrl", "https://launcher.exemple.fr/catalog/catalog.json", out var catalogError),
CatalogUrlError = catalogError,
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>();
@@ -167,6 +368,42 @@ public sealed class LauncherCatalog
return uri.ToString();
}
/// <summary>« apiUrl » : adresse https absolue, sans paramètres (http accepté seulement en local, pour les tests).</summary>
/// <summary>Adresse https absolue, sans paramètres ni identifiants (http autorisé seulement en local) ; null si absente ou invalide.</summary>
private static string? ReadTrustedUrl(JsonObject root, string name, string example, out string? error)
{
error = null;
var node = Get(root, name);
if (node is null) return null;
var url = node is JsonValue value && value.TryGetValue<string>(out var text) ? text : null;
if (url is not null && string.IsNullOrWhiteSpace(url)) return null;
if (url is null || !Uri.TryCreate(url.Trim(), UriKind.Absolute, out var uri) ||
!(uri.Scheme == Uri.UriSchemeHttps || (uri.Scheme == Uri.UriSchemeHttp && uri.IsLoopback)) ||
!string.IsNullOrEmpty(uri.Query) || !string.IsNullOrEmpty(uri.Fragment) || !string.IsNullOrEmpty(uri.UserInfo))
{
error = $"launcher.json : {name} ignorée, elle doit être une adresse https:// absolue sans paramètres, ex. \"{example}\".";
return null;
}
return uri.AbsoluteUri;
}
private static string? ReadApiUrl(JsonObject root, out string? error)
{
error = null;
var node = Get(root, "apiUrl");
if (node is null) return null;
var url = node is JsonValue value && value.TryGetValue<string>(out var text) ? text : null;
if (url is not null && string.IsNullOrWhiteSpace(url)) return null;
if (url is null || !Uri.TryCreate(url.Trim(), UriKind.Absolute, out var uri) ||
!(uri.Scheme == Uri.UriSchemeHttps || (uri.Scheme == Uri.UriSchemeHttp && uri.IsLoopback)) ||
!string.IsNullOrEmpty(uri.Query) || !string.IsNullOrEmpty(uri.Fragment) || !string.IsNullOrEmpty(uri.UserInfo))
{
error = "launcher.json : apiUrl ignorée, elle doit être une adresse https:// absolue sans paramètres, ex. \"https://launcher.exemple.fr/api/\".";
return null;
}
return uri.AbsoluteUri.EndsWith('/') ? uri.AbsoluteUri : uri.AbsoluteUri + "/";
}
/// <summary>Lecture d'une propriété sans tenir compte de la casse (gameId / GameId…).</summary>
private static JsonNode? Get(JsonObject obj, string name)
{
@@ -1,4 +1,5 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using GameLauncher.Core.Utils;
namespace GameLauncher.Core.Models;
@@ -47,6 +48,59 @@ 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; }
// ------------------------------------------------------------------ Catalogue (affichage modifiable)
/// <summary>
/// Nom AFFICHÉ, donné par le catalogue. <see cref="GameName"/> reste le nom de launcher.json : celui
/// du raccourci du Bureau, de « Applications installées » et du dossier des données du jeu.
/// </summary>
[JsonIgnore]
public string? DisplayName { get; set; }
/// <summary>Nom à afficher : celui du catalogue, sinon celui de launcher.json.</summary>
[JsonIgnore]
public string ShownName => string.IsNullOrWhiteSpace(DisplayName) ? GameName : DisplayName;
/// <summary>Présentation du jeu (catalogue, texte brut de plusieurs lignes).</summary>
[JsonIgnore]
public string? Description { get; set; }
/// <summary>Liens affichés sous la présentation (catalogue).</summary>
[JsonIgnore]
public IReadOnlyList<GameLink> Links { get; set; } = Array.Empty<GameLink>();
/// <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 +135,21 @@ 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);
}
}
/// <summary>Lien affiché sous la présentation d'un jeu (Discord, YouTube…), adresse https contrôlée.</summary>
/// <param name="Kind">Type de lien (icône intégrée) : website, discord, youtube, x, twitch, steam, other.</param>
public sealed record GameLink(string Kind, string Label, string Url);
@@ -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; }
}
@@ -1,5 +1,6 @@
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Core.Models;
namespace GameLauncher.Core.SelfUpdate;
@@ -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
{
@@ -0,0 +1,61 @@
using System.Net;
using System.Text.Json;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Core.Models;
using GameLauncher.Core.Utils;
namespace GameLauncher.Core.Services;
/// <summary>
/// Historique des notes de version lors d'une publication (ManifestBuilder). Avec --remote-manifest,
/// l'historique est repris du manifest EN LIGNE : les corrections faites depuis le menu
/// d'administration ne sont pas écrasées par le manifest.json local (resté dans son état d'origine).
/// </summary>
public static class ChangelogHistory
{
/// <summary>
/// Manifest publié (null s'il n'existe pas encore : première publication). Toute autre erreur
/// (réseau, fichier illisible) est signalée : publier sans elle perdrait les corrections en ligne.
/// </summary>
public static async Task<GameManifest?> FetchPublishedAsync(HttpDownloader http, Uri url, CancellationToken ct = default)
{
if (url.Scheme != Uri.UriSchemeHttps && !(url.Scheme == Uri.UriSchemeHttp && url.IsLoopback))
throw new ArgumentException($"Adresse du manifest en ligne : https:// attendu ({url}).");
string json;
try
{
json = await http.GetStringAsync(url, ct).ConfigureAwait(false);
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
try
{
return JsonSerializer.Deserialize<GameManifest>(json, JsonDefaults.Options)
?? throw new InvalidDataException("manifest vide");
}
catch (JsonException ex)
{
throw new InvalidDataException($"manifest illisible : {ex.Message}", ex);
}
}
/// <summary>
/// Notes de la version publiée en tête, puis l'historique (sans cette version si elle est republiée
/// avec de nouvelles notes), bornés comme par le launcher. Sans nouvelles notes (pas de --notes),
/// les notes existantes de la version, éventuellement corrigées en ligne, sont conservées.
/// </summary>
public static List<ChangelogEntry> Build(IEnumerable<ChangelogEntry?>? history, ChangelogEntry entry)
{
var list = (history ?? Enumerable.Empty<ChangelogEntry?>()).OfType<ChangelogEntry>().ToList();
if (!entry.IsEmpty)
{
list.RemoveAll(c => string.Equals(c.Version, entry.Version, StringComparison.Ordinal));
list.Insert(0, entry);
}
return ChangelogLimits.Normalize(list);
}
}
@@ -1,4 +1,5 @@
using System.Text.RegularExpressions;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Core.Models;
using GameLauncher.Core.Utils;
+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)
@@ -11,8 +11,15 @@ namespace GameLauncher.Core.Services;
public sealed class HttpDownloader : IDisposable
{
private static readonly TimeSpan StallTimeout = TimeSpan.FromSeconds(30);
private const int MaxSameHostRedirects = 3;
private readonly HttpClient _http;
/// <summary>
/// Client SANS redirection automatique (catalogue, images) : une redirection n'est suivie que vers le
/// même serveur, après vérification, et aucune requête ne part vers un autre serveur.
/// </summary>
private readonly HttpClient _strictHttp;
public HttpDownloader(string userAgent)
{
var handler = new SocketsHttpHandler
@@ -25,6 +32,15 @@ public sealed class HttpDownloader : IDisposable
};
_http = new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan };
_http.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent);
_strictHttp = new HttpClient(new SocketsHttpHandler
{
AllowAutoRedirect = false,
AutomaticDecompression = DecompressionMethods.None,
ConnectTimeout = TimeSpan.FromSeconds(15),
PooledConnectionLifetime = TimeSpan.FromMinutes(5)
}) { Timeout = Timeout.InfiniteTimeSpan };
_strictHttp.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent);
}
/// <summary>Taille maximale par défaut d'un document texte (manifest du jeu).</summary>
@@ -79,6 +95,64 @@ public sealed class HttpDownloader : IDisposable
}
}
/// <summary>
/// Télécharge un petit fichier en mémoire (catalogue, image) sans passer par les caches, en
/// refusant toute redirection vers un autre serveur ou vers une adresse non https.
/// Au-delà de <paramref name="maxBytes"/> octets, le téléchargement est interrompu.
/// </summary>
public async Task<byte[]> GetBytesAsync(Uri uri, long maxBytes, CancellationToken ct)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(TimeSpan.FromSeconds(30));
try
{
var current = uri;
HttpResponseMessage response;
for (var redirects = 0; ; redirects++)
{
using var request = new HttpRequestMessage(HttpMethod.Get, current);
request.Headers.CacheControl = new CacheControlHeaderValue { NoCache = true, NoStore = true };
request.Headers.Pragma.ParseAdd("no-cache");
response = await _strictHttp.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token)
.ConfigureAwait(false);
var status = (int)response.StatusCode;
if (status is < 300 or > 399 || status == 304) break;
// Redirection : suivie seulement vers le même serveur (hôte, schéma, port), quelques fois au plus.
var location = response.Headers.Location;
response.Dispose();
var next = location is null ? null : location.IsAbsoluteUri ? location : new Uri(current, location);
if (next is null || redirects >= MaxSameHostRedirects ||
!string.Equals(next.Host, uri.Host, StringComparison.OrdinalIgnoreCase) ||
next.Scheme != uri.Scheme || next.Port != uri.Port)
throw new InvalidDataException($"Redirection refusée ({uri.Host} → {next?.Host ?? "?"}).");
current = next;
}
using var _ = response;
response.EnsureSuccessStatusCode();
if (response.Content.Headers.ContentLength > maxBytes)
throw new InvalidDataException($"Réponse du serveur trop volumineuse ({uri.AbsolutePath}).");
await using var stream = await response.Content.ReadAsStreamAsync(timeout.Token).ConfigureAwait(false);
using var buffer = new MemoryStream();
var chunk = new byte[81920];
int read;
while ((read = await stream.ReadAsync(chunk, timeout.Token).ConfigureAwait(false)) > 0)
{
if (buffer.Length + read > maxBytes)
throw new InvalidDataException($"Réponse du serveur trop volumineuse ({uri.AbsolutePath}).");
buffer.Write(chunk, 0, read);
}
return buffer.ToArray();
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
throw new TimeoutException($"Le serveur ne répond pas ({uri.Host}).");
}
}
/// <summary>
/// Télécharge <paramref name="uri"/> vers <paramref name="destination"/>.
/// Si <paramref name="allowResume"/> est vrai et qu'un fichier partiel existe, le
@@ -155,5 +229,9 @@ public sealed class HttpDownloader : IDisposable
throw new IOException($"Téléchargement incomplet ({Format.Bytes(position)} sur {Format.Bytes(expectedSize)}).");
}
public void Dispose() => _http.Dispose();
public void Dispose()
{
_http.Dispose();
_strictHttp.Dispose();
}
}
@@ -1,4 +1,5 @@
using System.Text.Json;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Core.Models;
using GameLauncher.Core.Utils;
+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;
}
}
}
@@ -0,0 +1,25 @@
using GameLauncher.Modules.Admin.ViewModels;
namespace GameLauncher.Modules.Admin;
/// <summary>
/// Module « Administration » : la fenêtre d'administration elle-même (connexion, invitation,
/// verrouillage) et sa page d'accueil. Les autres pages (Jeux, Notes de version, Publication,
/// Administrateurs, Audit) seront déclarées par leurs propres modules.
/// </summary>
public sealed class AdminModule : ILauncherModule
{
public string Id => "admin";
public string DisplayName => "Administration";
public void Register(IModuleRegistry registry) =>
registry.AddAdminPanel(new AdminPanelDescriptor(
DashboardPanelViewModel.PanelId,
"Tableau de bord",
"Votre compte, la session en cours et l'état du serveur.",
"ViewDashboard",
RequiredPermission: null,
Order: 0,
context => new DashboardPanelViewModel(context)));
}
@@ -0,0 +1,25 @@
using GameLauncher.Contracts.Security;
namespace GameLauncher.Modules.Admin.Catalog;
/// <summary>
/// Module « Catalogue des jeux » : page « Jeux » du menu d'administration (informations modifiables
/// des jeux déclarés dans launcher.json, publiées dans catalog.json). Permission games.edit.
/// </summary>
public sealed class CatalogAdminModule : ILauncherModule
{
public string Id => "catalog";
public string DisplayName => "Catalogue des jeux";
public void Register(IModuleRegistry registry) =>
registry.AddAdminPanel(new AdminPanelDescriptor(
GamesPanelViewModel.PanelId,
"Jeux",
"Ce que le launcher affiche pour chaque jeu : nom, accroche, présentation, liens, apparence, ordre. " +
"Les jeux eux-mêmes restent déclarés dans launcher.json.",
"Gamepad",
AdminPermissions.GamesEdit,
Order: 10,
context => new GamesPanelViewModel(context)));
}
@@ -0,0 +1,311 @@
using System.Collections.ObjectModel;
using System.Globalization;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Core.Models;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Catalog;
/// <summary>Choix d'une liste (null : valeur par défaut de launcher.json).</summary>
public sealed record Choice(string? Value, string Label)
{
/// <summary>Texte affiché par les listes déroulantes.</summary>
public override string ToString() => Label;
}
/// <summary>Partie modifiable de la page « Jeux » (réglages du launcher ou un jeu).</summary>
public abstract class CatalogEditor : ObservableObject
{
private readonly Action _changed;
protected CatalogEditor(Action changed) => _changed = changed;
public abstract string Title { get; }
public abstract string Subtitle { get; }
public abstract string IconKey { get; }
/// <summary>Modifié depuis la dernière publication (pastille dans la liste).</summary>
public bool IsDirty
{
get => _isDirty;
internal set => SetProperty(ref _isDirty, value);
}
private bool _isDirty;
protected bool Set<T>(ref T field, T value, [System.Runtime.CompilerServices.CallerMemberName] string? name = null)
{
if (!SetProperty(ref field, value, name)) return false;
_changed();
return true;
}
protected void Changed() => _changed();
protected static string? Clean(string? text) => string.IsNullOrWhiteSpace(text) ? null : text.Trim();
}
/// <summary>Réglages du launcher : nom, jeu affiché au premier lancement.</summary>
public sealed class LauncherEditor : CatalogEditor
{
private string _launcherName = "";
private Choice _defaultGame;
public LauncherEditor(string declaredName, string? declaredDefault, IReadOnlyList<LauncherConfig> games, Action changed) : base(changed)
{
DeclaredName = declaredName;
GameChoices = new[] { new Choice(null, declaredDefault is null ? "Par défaut (premier jeu)" : $"Par défaut ({declaredDefault})") }
.Concat(games.Select(g => new Choice(g.GameId, $"{g.GameName} ({g.GameId})"))).ToList();
_defaultGame = GameChoices[0];
}
public override string Title => "Launcher";
public override string Subtitle => "Nom, jeu affiché au démarrage, ordre des jeux";
public override string IconKey => "Tune";
public string DeclaredName { get; }
public string DefaultNameHint => $"Vide : « {DeclaredName} » (launcher.json).";
public string LauncherName
{
get => _launcherName;
set => Set(ref _launcherName, value ?? "");
}
public IReadOnlyList<Choice> GameChoices { get; }
public Choice DefaultGame
{
get => _defaultGame;
set
{
if (value is not null) Set(ref _defaultGame, value); // null : liste de l'écran en cours de remplacement
}
}
internal void Load(CatalogLauncher? launcher)
{
_launcherName = launcher?.LauncherName ?? "";
_defaultGame = GameChoices.FirstOrDefault(c => c.Value is not null &&
string.Equals(c.Value, launcher?.DefaultGame, StringComparison.OrdinalIgnoreCase)) ?? GameChoices[0];
OnPropertyChanged(nameof(LauncherName));
OnPropertyChanged(nameof(DefaultGame));
}
internal CatalogLauncher? ToCatalog(IReadOnlyList<string> order, IReadOnlyList<string> declaredOrder)
{
var name = Clean(LauncherName);
var gameOrder = order.SequenceEqual(declaredOrder, StringComparer.OrdinalIgnoreCase) ? null : order.ToList();
return name is null && DefaultGame.Value is null && gameOrder is null
? null
: new CatalogLauncher { LauncherName = name, DefaultGame = DefaultGame.Value, GameOrder = gameOrder };
}
}
/// <summary>Un jeu déclaré dans launcher.json, et ses informations modifiables.</summary>
public sealed class GameEditor : CatalogEditor
{
private string _gameName = "", _tagline = "", _description = "", _websiteUrl = "", _icon = "", _background = "", _interval = "";
private Choice _theme;
private bool _hidden;
public GameEditor(LauncherConfig declared, IReadOnlyList<string> themes, Action changed) : base(changed)
{
Declared = declared;
_builtInThemeChoices = new[] { new Choice(null, $"Par défaut ({ThemeOf(declared)})") }
.Concat(themes.Select(t => new Choice(t, t))).ToList();
ThemeChoices = _builtInThemeChoices;
_theme = ThemeChoices[0];
LinkKinds = CatalogLinkKinds.All.Select(k => new Choice(k, CatalogLinkKinds.DefaultLabel(k))).ToList();
AddLinkCommand = new RelayCommand(AddLink, () => Links.Count < CatalogRules.MaxLinks);
Links.CollectionChanged += (_, _) =>
{
AddLinkCommand.RaiseCanExecuteChanged();
Changed();
};
}
public LauncherConfig Declared { get; }
public string GameId => Declared.GameId;
public override string Title => string.IsNullOrWhiteSpace(GameName) ? Declared.GameName : GameName.Trim();
public override string Subtitle => Hidden ? $"{GameId} · masqué" : GameId;
public override string IconKey => Hidden ? "EyeOffOutline" : "Gamepad";
// ------------------------------------------------------------------ Valeurs (vide : launcher.json)
public string GameName
{
get => _gameName;
set
{
if (Set(ref _gameName, value ?? "")) OnPropertyChanged(nameof(Title));
}
}
public string Tagline { get => _tagline; set => Set(ref _tagline, value ?? ""); }
public string Description { get => _description; set => Set(ref _description, value ?? ""); }
public string WebsiteUrl { get => _websiteUrl; set => Set(ref _websiteUrl, value ?? ""); }
public string Icon { get => _icon; set => Set(ref _icon, value ?? ""); }
public string Background { get => _background; set => Set(ref _background, value ?? ""); }
/// <summary>Minutes entre deux recherches de mise à jour (vide : launcher.json ; 0 : désactivé).</summary>
public string UpdateInterval { get => _interval; set => Set(ref _interval, value ?? ""); }
private readonly IReadOnlyList<Choice> _builtInThemeChoices;
/// <summary>Thèmes intégrés (+ celui du catalogue publié s'il est inconnu de ce launcher, pour ne pas l'effacer en silence).</summary>
public IReadOnlyList<Choice> ThemeChoices { get; private set; }
public Choice Theme
{
get => _theme;
set
{
// null : liste des thèmes remplacée à l'écran (rechargement), pas un choix de l'administrateur.
if (value is not null) Set(ref _theme, value);
}
}
public bool Hidden
{
get => _hidden;
set
{
if (!Set(ref _hidden, value)) return;
OnPropertyChanged(nameof(Subtitle));
OnPropertyChanged(nameof(IconKey));
}
}
public ObservableCollection<LinkEditor> Links { get; } = new();
public IReadOnlyList<Choice> LinkKinds { get; }
public RelayCommand AddLinkCommand { get; }
// ------------------------------------------------------------------ Valeurs de launcher.json (rappel)
public string NameHint => $"Vide : « {Declared.GameName} ». Le raccourci et « Applications installées » gardent ce nom.";
public string TaglineHint => string.IsNullOrWhiteSpace(Declared.Tagline) ? "Vide : aucune accroche." : $"Vide : « {Declared.Tagline} ».";
public string WebsiteHint => string.IsNullOrWhiteSpace(Declared.WebsiteUrl) ? "Adresse https. Vide : aucun site." : $"Adresse https. Vide : {Declared.WebsiteUrl}";
public string IntervalHint => $"0 : désactivé, ou {CatalogRules.MinUpdateInterval} à {CatalogRules.MaxUpdateInterval} minutes. Vide : {Declared.UpdateCheckIntervalMinutes} (launcher.json).";
public string ImageHint => "Image .png ou .jpg (4 Mo au plus) déposée sur le serveur du catalogue, ex. https://…/catalog/images/fond.jpg. " +
"Les launchers gardent l'image en cache : pour la changer, déposez-la sous un NOUVEAU nom. Vide : image de launcher.json.";
private static string ThemeOf(LauncherConfig config) => string.IsNullOrWhiteSpace(config.Theme) ? "default" : config.Theme;
private void AddLink()
{
if (Links.Count >= CatalogRules.MaxLinks) return;
Links.Add(new LinkEditor(LinkKinds, CatalogLinkKinds.Website, "", "", Changed, RemoveLink));
}
private void RemoveLink(LinkEditor link) => Links.Remove(link);
internal void Load(CatalogGame? game)
{
_gameName = game?.GameName ?? "";
_tagline = game?.Tagline ?? "";
_description = game?.Description ?? "";
_websiteUrl = game?.WebsiteUrl ?? "";
_icon = game?.Icon ?? "";
_background = game?.Background ?? "";
_interval = game?.UpdateCheckIntervalMinutes?.ToString(CultureInfo.InvariantCulture) ?? "";
_hidden = game?.Hidden == true;
ThemeChoices = _builtInThemeChoices;
_theme = ThemeChoices.FirstOrDefault(c => c.Value is not null && string.Equals(c.Value, game?.Theme, StringComparison.OrdinalIgnoreCase))
?? ThemeChoices[0];
if (_theme.Value is null && game?.Theme is { } unknown)
{
// Publié par une autre version du launcher : conservé (les launchers qui ne le connaissent pas l'ignorent).
_theme = new Choice(unknown, $"{unknown} (inconnu de ce launcher)");
ThemeChoices = _builtInThemeChoices.Append(_theme).ToList();
}
Links.Clear();
foreach (var link in game?.Links ?? new List<CatalogLink>())
Links.Add(new LinkEditor(LinkKinds, link.Kind, link.Label ?? "", link.Url, Changed, RemoveLink));
foreach (var name in new[] { nameof(GameName), nameof(Tagline), nameof(Description), nameof(WebsiteUrl), nameof(Icon), nameof(Background),
nameof(UpdateInterval), nameof(Hidden), nameof(ThemeChoices), nameof(Theme), nameof(Title), nameof(Subtitle), nameof(IconKey) })
OnPropertyChanged(name);
}
/// <summary>Valeurs saisies (contrôlées ensuite par <see cref="CatalogRules"/>) ; null si tout est par défaut.</summary>
internal CatalogGame? ToCatalog(List<string> errors)
{
int? interval = null;
if (!string.IsNullOrWhiteSpace(UpdateInterval))
{
if (int.TryParse(UpdateInterval.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out var minutes)) interval = minutes;
else errors.Add($"Jeu « {GameId} » : intervalle de mise à jour : nombre de minutes attendu.");
}
var links = Links.Where(l => !l.IsEmpty)
.Select(l => new CatalogLink { Kind = l.Kind.Value ?? CatalogLinkKinds.Other, Label = Clean(l.Label), Url = l.Url.Trim() })
.ToList();
var game = new CatalogGame
{
GameName = Clean(GameName),
Tagline = Clean(Tagline),
Description = Clean(Description),
WebsiteUrl = Clean(WebsiteUrl),
Links = links.Count == 0 ? null : links,
Theme = Theme.Value,
Icon = Clean(Icon),
Background = Clean(Background),
UpdateCheckIntervalMinutes = interval,
Hidden = Hidden ? true : null
};
return game is { GameName: null, Tagline: null, Description: null, WebsiteUrl: null, Links: null, Theme: null, Icon: null,
Background: null, UpdateCheckIntervalMinutes: null, Hidden: null }
? null
: game;
}
}
/// <summary>Lien d'un jeu (Discord, YouTube…).</summary>
public sealed class LinkEditor : ObservableObject
{
private readonly Action _changed;
private Choice _kind;
private string _label, _url;
public LinkEditor(IReadOnlyList<Choice> kinds, string kind, string label, string url, Action changed, Action<LinkEditor> remove)
{
Kinds = kinds;
_kind = kinds.FirstOrDefault(k => k.Value == kind) ?? kinds[^1];
_label = label;
_url = url;
_changed = changed;
RemoveCommand = new RelayCommand(() => remove(this));
}
public IReadOnlyList<Choice> Kinds { get; }
public Choice Kind
{
get => _kind;
set
{
if (value is not null && SetProperty(ref _kind, value)) _changed(); // null : liste de l'écran en cours de remplacement
}
}
public string Label
{
get => _label;
set
{
if (SetProperty(ref _label, value ?? "")) _changed();
}
}
public string Url
{
get => _url;
set
{
if (SetProperty(ref _url, value ?? "")) _changed();
}
}
public RelayCommand RemoveCommand { get; }
internal bool IsEmpty => string.IsNullOrWhiteSpace(Url) && string.IsNullOrWhiteSpace(Label);
}
@@ -0,0 +1,269 @@
using System.Collections.ObjectModel;
using System.Text.Json;
using GameLauncher.ApiClient;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Core.Utils;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Catalog;
/// <summary>
/// Page « Jeux » : informations modifiables des jeux déclarés dans launcher.json, publiées dans le
/// catalogue (catalog.json) que les launchers des joueurs lisent au démarrage.
/// • une liste : « Launcher » (nom, jeu par défaut) puis les jeux, dans l'ordre d'affichage (↑ ↓) ;
/// • champ vide = valeur de launcher.json (rappelée sous le champ) ;
/// • « Publier » : contrôle complet (mêmes règles que le serveur), requête signée, révision attendue
/// (si quelqu'un a publié entre-temps : rechargement proposé, rien n'est écrasé).
/// </summary>
public sealed class GamesPanelViewModel : AdminPanelViewModel
{
public const string PanelId = "catalog.games";
private CatalogState? _state;
private CatalogEditor? _selected;
private string? _statusMessage;
private bool _loading;
private string _publishedSnapshot = "";
private bool _hasChanges;
private bool _hasConflict;
public GamesPanelViewModel(IAdminContext context) : base(context)
{
Launcher = new LauncherEditor(context.LauncherName, context.DefaultGameId, context.Games, OnEdited);
foreach (var game in context.Games) Games.Add(new GameEditor(game, context.Themes, OnEdited));
RebuildItems(Launcher);
PublishCommand = new AsyncRelayCommand(PublishAsync, () => !IsBusy && HasChanges && PublishingEnabled);
ReloadCommand = new AsyncRelayCommand(ReloadAsync, () => !IsBusy);
MoveUpCommand = new RelayCommand(() => Move(-1), () => CanMove(-1));
MoveDownCommand = new RelayCommand(() => Move(+1), () => CanMove(+1));
}
public LauncherEditor Launcher { get; }
/// <summary>Jeux dans l'ordre d'affichage du launcher.</summary>
public ObservableCollection<GameEditor> Games { get; } = new();
/// <summary>Liste affichée : « Launcher », puis les jeux.</summary>
public ObservableCollection<CatalogEditor> Items { get; } = new();
public CatalogEditor? Selected
{
get => _selected;
set
{
if (!SetProperty(ref _selected, value)) return;
MoveUpCommand.RaiseCanExecuteChanged();
MoveDownCommand.RaiseCanExecuteChanged();
}
}
public bool PublishingEnabled => _state?.PublishingEnabled == true;
public bool ShowPublishingDisabled => _state is not null && !_state.PublishingEnabled;
public string? PublicUrl => _state?.PublicUrl;
/// <summary>« Révision 12, publiée le 31/10/2026 à 08:42 » / « Jamais publié ».</summary>
public string RevisionText => _state?.Catalog is not { } catalog ? "" :
catalog.Revision == 0 ? "Jamais publié : le launcher utilise les valeurs de launcher.json."
: $"Révision {catalog.Revision}" + (catalog.UpdatedAt is { } at ? $", publiée le {at.ToLocalTime():dd/MM/yyyy à HH:mm}" : "") + ".";
public bool HasChanges
{
get => _hasChanges;
private set
{
if (!SetProperty(ref _hasChanges, value)) return;
PublishCommand.RaiseCanExecuteChanged();
}
}
/// <summary>Quelqu'un a publié entre-temps : il faut recharger.</summary>
public bool HasConflict
{
get => _hasConflict;
private set => SetProperty(ref _hasConflict, value);
}
public string? StatusMessage
{
get => _statusMessage;
private set
{
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
}
}
public bool HasStatus => !string.IsNullOrEmpty(StatusMessage);
public AsyncRelayCommand PublishCommand { get; }
public AsyncRelayCommand ReloadCommand { get; }
public RelayCommand MoveUpCommand { get; }
public RelayCommand MoveDownCommand { get; }
/// <summary>Faux pendant un chargement ou une publication : la saisie attend la réponse du serveur.</summary>
public bool IsEditable => !IsBusy;
protected override void OnBusyChanged()
{
OnPropertyChanged(nameof(IsEditable));
PublishCommand.RaiseCanExecuteChanged();
ReloadCommand.RaiseCanExecuteChanged();
}
protected override Task OnActivatedAsync(bool firstTime) => firstTime ? LoadAsync() : Task.CompletedTask;
// ------------------------------------------------------------------ Chargement
public Task LoadAsync() => RunAsync(async () =>
{
var state = await Context.Session.GetCatalogAsync();
Apply(state);
StatusMessage = null;
});
private async Task ReloadAsync()
{
if (HasChanges && !Context.Dialogs.Confirm("Abandonner les modifications ?",
"Les modifications non publiées seront perdues : la page affichera le catalogue actuellement publié.",
"Abandonner et recharger"))
return;
await LoadAsync();
}
/// <summary>Affiche un catalogue (chargé ou publié) et en fait la référence « sans modification ».</summary>
private void Apply(CatalogState state)
{
_loading = true;
try
{
_state = state;
var catalog = state.Catalog;
Launcher.Load(catalog.Launcher);
foreach (var game in Games) game.Load(catalog.Games.TryGetValue(game.GameId, out var entry) ? entry : null);
// Ordre : celui du catalogue, puis les autres jeux dans l'ordre de launcher.json.
var ordered = new List<GameEditor>();
foreach (var id in catalog.Launcher?.GameOrder ?? new List<string>())
if (Games.FirstOrDefault(g => string.Equals(g.GameId, id, StringComparison.OrdinalIgnoreCase)) is { } game && !ordered.Contains(game))
ordered.Add(game);
ordered.AddRange(Context.Games.Select(d => Games.First(g => g.Declared == d)).Where(g => !ordered.Contains(g)));
var selected = Selected;
Games.Clear();
foreach (var game in ordered) Games.Add(game);
RebuildItems(selected);
}
finally
{
_loading = false;
}
_publishedSnapshot = Snapshot(BuildCatalog(new List<string>()));
HasConflict = false;
RefreshDirty();
foreach (var name in new[] { nameof(PublishingEnabled), nameof(ShowPublishingDisabled), nameof(PublicUrl), nameof(RevisionText) })
OnPropertyChanged(name);
PublishCommand.RaiseCanExecuteChanged();
}
// ------------------------------------------------------------------ Publication
private Task PublishAsync() => RunAsync(async () =>
{
if (_state is not { } state) return;
var errors = new List<string>();
var draft = BuildCatalog(errors);
var imageHost = Uri.TryCreate(state.PublicUrl, UriKind.Absolute, out var url) ? url.Host : null;
var normalized = CatalogRules.Normalize(draft, imageHost, errors);
if (errors.Count == 0 && CatalogRules.PublishedSize(normalized) > CatalogRules.MaxFileBytes)
errors.Add($"Catalogue trop volumineux (au plus {CatalogRules.MaxFileBytes / 1024} Ko) : raccourcissez les descriptions.");
if (errors.Count > 0)
{
Context.Dialogs.ShowMessage("Valeurs à corriger", string.Join("\n", errors.Distinct().Take(12)), ModuleMessageKind.Warning);
return;
}
try
{
var published = await Context.Session.PublishCatalogAsync(draft, state.Catalog.Revision);
Apply(published);
StatusMessage = $"Catalogue publié (révision {published.Catalog.Revision}). Les launchers l'appliquent à leur prochain démarrage.";
Log.Info($"Administration : catalogue publié (révision {published.Catalog.Revision}).");
}
catch (ApiException ex) when (ex.Status == 409 && ex.Code == ApiErrorCodes.RevisionConflict)
{
HasConflict = true;
Context.Dialogs.ShowMessage("Publié entre-temps",
"Quelqu'un a publié le catalogue pendant vos modifications. Rechargez la page pour partir de sa version " +
"(vos modifications non publiées seront perdues : notez-les si besoin).", ModuleMessageKind.Warning);
}
});
/// <summary>Catalogue saisi (ordre affiché, champs non vides).</summary>
private CatalogDocument BuildCatalog(List<string> errors)
{
var games = new Dictionary<string, CatalogGame>(StringComparer.OrdinalIgnoreCase);
foreach (var game in Games)
if (game.ToCatalog(errors) is { } entry) games[game.GameId] = entry;
return new CatalogDocument
{
Launcher = Launcher.ToCatalog(Games.Select(g => g.GameId).ToList(), Context.Games.Select(g => g.GameId).ToList()),
Games = games
};
}
// ------------------------------------------------------------------ Modifications
private void OnEdited()
{
if (_loading) return;
RefreshDirty();
}
private void RefreshDirty()
{
var errors = new List<string>();
var current = BuildCatalog(errors);
HasChanges = Snapshot(current) != _publishedSnapshot || errors.Count > 0;
var published = string.IsNullOrEmpty(_publishedSnapshot) ? null : JsonSerializer.Deserialize<CatalogDocument>(_publishedSnapshot, ApiJson.Options);
Launcher.IsDirty = Snapshot(current.Launcher) != Snapshot(published?.Launcher);
foreach (var game in Games)
{
current.Games.TryGetValue(game.GameId, out var now);
CatalogGame? before = null;
published?.Games.TryGetValue(game.GameId, out before);
game.IsDirty = Snapshot(now) != Snapshot(before);
}
}
private bool CanMove(int delta) =>
Selected is GameEditor game && Games.IndexOf(game) is var index and >= 0 && index + delta >= 0 && index + delta < Games.Count;
private void Move(int delta)
{
if (Selected is not GameEditor game || !CanMove(delta)) return;
var index = Games.IndexOf(game);
Games.Move(index, index + delta);
RebuildItems(game);
OnEdited();
}
/// <summary>
/// Reconstruit la liste affichée puis resélectionne <paramref name="select"/> : la liste de l'écran
/// vide sa sélection quand ses éléments sont retirés, la notification la rétablit.
/// </summary>
private void RebuildItems(CatalogEditor? select)
{
Items.Clear();
Items.Add(Launcher);
foreach (var game in Games) Items.Add(game);
_selected = select is not null && Items.Contains(select) ? select : Items.FirstOrDefault();
OnPropertyChanged(nameof(Selected));
MoveUpCommand?.RaiseCanExecuteChanged();
MoveDownCommand?.RaiseCanExecuteChanged();
}
private static string Snapshot(object? value) => value is null ? "" : JsonSerializer.Serialize(value, ApiJson.Options);
}
@@ -0,0 +1,25 @@
using GameLauncher.Contracts.Security;
namespace GameLauncher.Modules.Admin.Changelog;
/// <summary>
/// Module « Notes de version » : page du menu d'administration pour corriger les notes des versions
/// déjà publiées (manifest.json du serveur). Permission changelog.edit.
/// </summary>
public sealed class ChangelogAdminModule : ILauncherModule
{
public string Id => "changelog";
public string DisplayName => "Notes de version";
public void Register(IModuleRegistry registry) =>
registry.AddAdminPanel(new AdminPanelDescriptor(
ChangelogPanelViewModel.PanelId,
"Notes de version",
"Corrigez les notes des versions déjà publiées : texte, sections, étiquettes, sous-points. " +
"Une nouvelle version et ses notes se publient toujours avec ManifestBuilder (notes.txt).",
"FileDocument",
AdminPermissions.ChangelogEdit,
Order: 20,
context => new ChangelogPanelViewModel(context)));
}
@@ -0,0 +1,401 @@
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Changelog;
/// <summary>
/// Notes d'une version, en cours de modification : en-tête (titre, sous-titre, introduction), puis
/// sections › éléments. <see cref="Preview"/> est l'entrée telle que le launcher l'affichera (mêmes
/// bornes, mêmes modèles d'affichage), recalculée à chaque modification.
/// </summary>
public sealed class VersionEditor : ObservableObject
{
private readonly Action _changed;
private readonly Func<string, string, bool> _confirm;
private string _title = "", _subtitle = "", _introduction = "";
private string _snapshot = "";
private bool _isDirty, _showPreview, _loading;
private ChangelogEntry _preview;
private IReadOnlyList<string> _limitErrors = Array.Empty<string>();
/// <param name="confirm">Confirmation (titre, message) avant de retirer une section qui contient des éléments.</param>
/// <param name="changed">Prévient la page (bouton « Enregistrer », pastilles).</param>
public VersionEditor(ChangelogEntry published, Func<string, string, bool> confirm, Action changed)
{
Version = published.Version;
Date = published.Date;
_confirm = confirm;
_changed = changed;
_preview = new ChangelogEntry { Version = Version, Date = Date };
Sections.CollectionChanged += OnSectionsChanged;
AddSectionCommand = new RelayCommand(AddSection, () => Sections.Count < ChangelogLimits.MaxSectionsPerEntry);
Load(published);
}
public string Version { get; }
public DateTimeOffset Date { get; }
public string DateText => Date == default ? "" : _preview.DateText;
// ------------------------------------------------------------------ En-tête
public string Title { get => _title; set => Set(ref _title, value ?? ""); }
/// <summary>Sous-titre : une ou plusieurs lignes.</summary>
public string Subtitle { get => _subtitle; set => Set(ref _subtitle, value ?? ""); }
/// <summary>Paragraphes d'introduction, séparés par une ligne vide.</summary>
public string Introduction { get => _introduction; set => Set(ref _introduction, value ?? ""); }
public ObservableCollection<SectionEditor> Sections { get; } = new();
public RelayCommand AddSectionCommand { get; }
// ------------------------------------------------------------------ État
/// <summary>Modifié depuis le chargement (ou le dernier enregistrement).</summary>
public bool IsDirty
{
get => _isDirty;
private set => SetProperty(ref _isDirty, value);
}
/// <summary>Affichage : modification (faux) ou aperçu tel que le verront les joueurs (vrai).</summary>
public bool ShowPreview
{
get => _showPreview;
set
{
if (!SetProperty(ref _showPreview, value)) return;
OnPropertyChanged(nameof(IsEditMode));
OnPropertyChanged(nameof(IsPreviewMode));
}
}
public bool IsEditMode { get => !_showPreview; set { if (value) ShowPreview = false; } }
public bool IsPreviewMode { get => _showPreview; set { if (value) ShowPreview = true; } }
/// <summary>Notes telles que le launcher les affichera.</summary>
public ChangelogEntry Preview
{
get => _preview;
private set => SetProperty(ref _preview, value);
}
/// <summary>Limites dépassées (rien n'est tronqué : à corriger avant d'enregistrer).</summary>
public IReadOnlyList<string> LimitErrors
{
get => _limitErrors;
private set
{
_limitErrors = value;
OnPropertyChanged();
OnPropertyChanged(nameof(HasLimitErrors));
}
}
public bool HasLimitErrors => _limitErrors.Count > 0;
/// <summary>« 3 sections · 14 éléments ».</summary>
public string CountText
{
get
{
var items = Sections.Sum(s => s.Items.Count);
return $"{Sections.Count} section{(Sections.Count > 1 ? "s" : "")} · {items} élément{(items > 1 ? "s" : "")} " +
$"(au plus {ChangelogLimits.MaxItemsPerEntry})";
}
}
// ------------------------------------------------------------------ Chargement, conversion
/// <summary>Affiche des notes publiées et en fait la référence « sans modification ».</summary>
public void Load(ChangelogEntry entry)
{
_loading = true;
try
{
_title = entry.Title ?? "";
_subtitle = entry.Subtitle ?? "";
_introduction = string.Join("\n\n", entry.Introduction ?? new List<string>());
Sections.Clear();
// Ancien format (liste à plat) : présenté en section(s) sans titre, enregistré au format enrichi.
var sections = entry.Sections.Count > 0
? entry.Sections
: (entry.Changes ?? new List<string>())
.Chunk(ChangelogLimits.MaxItemsPerSection)
.Select(chunk => new ChangelogSection { Items = chunk.Select(c => new ChangelogItem { Text = c }).ToList() })
.ToList();
foreach (var section in sections) Sections.Add(new SectionEditor(section, this));
foreach (var name in new[] { nameof(Title), nameof(Subtitle), nameof(Introduction) }) OnPropertyChanged(name);
}
finally
{
_loading = false;
}
_snapshot = Snapshot(Normalized(ToEntry()));
Refresh();
}
/// <summary>Notes saisies (non tronquées) : c'est ce qui est envoyé au serveur.</summary>
public ChangelogEntry ToEntry()
{
var entry = new ChangelogEntry
{
Version = Version,
Date = Date,
Title = Clean(Title),
Subtitle = Clean(Subtitle.Replace("\r\n", "\n")),
Introduction = Paragraphs(Introduction),
Sections = Sections.Select(s => s.ToSection()).ToList()
};
// Liste à plat des anciens launchers : régénérée par le serveur à l'enregistrement (requête allégée).
if (entry.Sections.Count == 0) entry.Changes = new List<string>();
return entry;
}
// ------------------------------------------------------------------ Modifications
internal void Changed()
{
if (_loading) return;
Refresh();
}
private void Refresh()
{
var entry = ToEntry();
var preview = Normalized(entry);
var errors = ChangelogLimits.Check(entry);
if (preview is null) errors.Insert(0, "Les notes ne peuvent pas être vides : gardez au moins un titre ou un élément.");
LimitErrors = errors;
Preview = preview ?? new ChangelogEntry { Version = Version, Date = Date };
IsDirty = Snapshot(preview) != _snapshot;
OnPropertyChanged(nameof(CountText));
AddSectionCommand.RaiseCanExecuteChanged();
_changed();
}
private bool Set<T>(ref T field, T value, [System.Runtime.CompilerServices.CallerMemberName] string? name = null)
{
if (!SetProperty(ref field, value, name)) return false;
Changed();
return true;
}
private void AddSection()
{
if (Sections.Count >= ChangelogLimits.MaxSectionsPerEntry) return;
var section = new SectionEditor(new ChangelogSection(), this);
section.AddChangeCommand.Execute(null);
Sections.Add(section);
}
internal void MoveSection(SectionEditor section, int delta) => ListMoves.Move(Sections, section, delta);
internal void RemoveSection(SectionEditor section)
{
if (section.Items.Any(i => !i.IsEmpty) &&
!_confirm("Retirer la section ?", $"La section « {(string.IsNullOrWhiteSpace(section.Title) ? "sans titre" : section.Title.Trim())} » " +
$"et ses {section.Items.Count} élément(s) seront retirés de ces notes (rien n'est enregistré avant « Enregistrer »)."))
return;
Sections.Remove(section);
}
private void OnSectionsChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
foreach (var section in Sections) section.RefreshMoves();
Changed();
}
// ------------------------------------------------------------------ Outils
private static ChangelogEntry? Normalized(ChangelogEntry entry) => ChangelogLimits.Normalize(new[] { entry }).FirstOrDefault();
private static string Snapshot(ChangelogEntry? entry) => entry is null ? "" : JsonSerializer.Serialize(entry, ApiJson.Options);
internal static string? Clean(string? text) => string.IsNullOrWhiteSpace(text) ? null : text.Trim();
/// <summary>Paragraphes séparés par une (ou plusieurs) ligne(s) vide(s) ; retours à la ligne simples gardés en espaces.</summary>
private static List<string> Paragraphs(string text) =>
System.Text.RegularExpressions.Regex.Split(text.Replace("\r\n", "\n"), @"\n[ \t]*\n")
.Select(p => Clean(p.Replace('\n', ' ')))
.OfType<string>()
.ToList();
/// <summary>Une ligne par élément (sous-points).</summary>
internal static List<string> Lines(string text) =>
text.Replace("\r\n", "\n").Split('\n').Select(Clean).OfType<string>().ToList();
}
/// <summary>Section des notes (titre facultatif) et ses éléments.</summary>
public sealed class SectionEditor : ObservableObject
{
private readonly VersionEditor _version;
private string _title;
public SectionEditor(ChangelogSection section, VersionEditor version)
{
_version = version;
_title = section.Title ?? "";
AddChangeCommand = new RelayCommand(() => Add(comment: false), CanAdd);
AddCommentCommand = new RelayCommand(() => Add(comment: true), CanAdd);
foreach (var item in section.Items ?? new List<ChangelogItem>()) Items.Add(new ItemEditor(item, this));
Items.CollectionChanged += (_, _) =>
{
foreach (var item in Items) item.RefreshMoves();
OnPropertyChanged(nameof(CountText));
AddChangeCommand.RaiseCanExecuteChanged();
AddCommentCommand.RaiseCanExecuteChanged();
_version.Changed();
};
MoveUpCommand = new RelayCommand(() => _version.MoveSection(this, -1), () => _version.Sections.IndexOf(this) > 0);
MoveDownCommand = new RelayCommand(() => _version.MoveSection(this, +1),
() => _version.Sections.IndexOf(this) is var i && i >= 0 && i < _version.Sections.Count - 1);
RemoveCommand = new RelayCommand(() => _version.RemoveSection(this));
}
/// <summary>Titre de la section (facultatif), ex. « PROGRESSION ».</summary>
public string Title
{
get => _title;
set
{
if (SetProperty(ref _title, value ?? "")) _version.Changed();
}
}
public ObservableCollection<ItemEditor> Items { get; } = new();
public string CountText => $"{Items.Count} élément{(Items.Count > 1 ? "s" : "")}";
public RelayCommand AddChangeCommand { get; }
public RelayCommand AddCommentCommand { get; }
public RelayCommand MoveUpCommand { get; }
public RelayCommand MoveDownCommand { get; }
public RelayCommand RemoveCommand { get; }
internal VersionEditor Version => _version;
internal ChangelogSection ToSection() => new()
{
Title = VersionEditor.Clean(Title),
Items = Items.Select(i => i.ToItem()).ToList()
};
internal void RefreshMoves()
{
MoveUpCommand.RaiseCanExecuteChanged();
MoveDownCommand.RaiseCanExecuteChanged();
}
internal void MoveItem(ItemEditor item, int delta) => ListMoves.Move(Items, item, delta);
internal void RemoveItem(ItemEditor item) => Items.Remove(item);
private bool CanAdd() => Items.Count < ChangelogLimits.MaxItemsPerSection;
private void Add(bool comment)
{
if (!CanAdd()) return;
Items.Add(new ItemEditor(new ChangelogItem { Type = comment ? ChangelogItem.CommentType : null }, this));
}
}
/// <summary>Élément d'une section : une modification (étiquette, sous-points, note) ou un commentaire.</summary>
public sealed class ItemEditor : ObservableObject
{
public static readonly IReadOnlyList<Choice> Kinds = new[]
{
new Choice(ChangelogItem.ChangeType, "Modification"),
new Choice(ChangelogItem.CommentType, "Commentaire")
};
private readonly SectionEditor _section;
private Choice _kind;
private string _text, _tag, _details, _note;
public ItemEditor(ChangelogItem item, SectionEditor section)
{
_section = section;
_kind = item.IsComment ? Kinds[1] : Kinds[0];
_text = item.Text ?? "";
_tag = item.Tag ?? "";
_details = string.Join("\n", item.Details ?? new List<string>());
_note = item.Note ?? "";
MoveUpCommand = new RelayCommand(() => _section.MoveItem(this, -1), () => _section.Items.IndexOf(this) > 0);
MoveDownCommand = new RelayCommand(() => _section.MoveItem(this, +1),
() => _section.Items.IndexOf(this) is var i && i >= 0 && i < _section.Items.Count - 1);
RemoveCommand = new RelayCommand(() => _section.RemoveItem(this));
}
public IReadOnlyList<Choice> KindChoices => Kinds;
public Choice Kind
{
get => _kind;
set
{
// null : liste de l'écran en cours de remplacement, pas un choix de l'administrateur.
if (value is null || !SetProperty(ref _kind, value)) return;
OnPropertyChanged(nameof(IsComment));
OnPropertyChanged(nameof(IsChange));
_section.Version.Changed();
}
}
public bool IsComment => _kind.Value == ChangelogItem.CommentType;
public bool IsChange => !IsComment;
public string Text { get => _text; set => Set(ref _text, value ?? ""); }
/// <summary>Étiquette en pastille, ex. « NOUVEAU » (modifications uniquement).</summary>
public string Tag { get => _tag; set => Set(ref _tag, value ?? ""); }
/// <summary>Sous-points : un par ligne (modifications uniquement).</summary>
public string Details { get => _details; set => Set(ref _details, value ?? ""); }
/// <summary>Texte de conclusion après les sous-points (modifications uniquement).</summary>
public string Note { get => _note; set => Set(ref _note, value ?? ""); }
public RelayCommand MoveUpCommand { get; }
public RelayCommand MoveDownCommand { get; }
public RelayCommand RemoveCommand { get; }
internal bool IsEmpty => string.IsNullOrWhiteSpace(Text) && string.IsNullOrWhiteSpace(Details);
internal ChangelogItem ToItem() => IsComment
? new ChangelogItem { Type = ChangelogItem.CommentType, Text = Text.Trim() }
: new ChangelogItem
{
Text = Text.Trim(),
Tag = VersionEditor.Clean(Tag),
Details = VersionEditor.Lines(Details),
Note = VersionEditor.Clean(Note)
};
internal void RefreshMoves()
{
MoveUpCommand.RaiseCanExecuteChanged();
MoveDownCommand.RaiseCanExecuteChanged();
}
private void Set(ref string field, string value, [System.Runtime.CompilerServices.CallerMemberName] string? name = null)
{
if (SetProperty(ref field, value, name)) _section.Version.Changed();
}
}
internal static class ListMoves
{
public static void Move<T>(ObservableCollection<T> list, T item, int delta)
{
var index = list.IndexOf(item);
var target = index + delta;
if (index < 0 || target < 0 || target >= list.Count) return;
list.Move(index, target);
}
}
@@ -0,0 +1,243 @@
using System.Collections.ObjectModel;
using System.Text.Json;
using GameLauncher.ApiClient;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Core.Utils;
using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Changelog;
/// <summary>
/// Page « Notes de version » : modification des notes des versions DÉJÀ publiées, dans le manifest.json
/// du serveur (la création d'une version reste l'affaire de ManifestBuilder et du .bat).
/// • liste des versions (pastille « modifié »), éditeur en-tête › sections › éléments, aperçu en
/// direct avec les modèles d'affichage du launcher (Themes/Changelog.xaml) ;
/// • mêmes bornes que le launcher, vérifiées avant l'envoi (rien n'est tronqué en silence) ;
/// • « Enregistrer » : une requête signée par version modifiée, avec l'empreinte du manifest lu
/// (si le manifest a changé entre-temps, rien n'est écrasé et le rechargement est proposé).
/// </summary>
public sealed class ChangelogPanelViewModel : AdminPanelViewModel
{
public const string PanelId = "changelog.notes";
private ChangelogState? _state;
private Choice? _selectedGame;
private VersionEditor? _selectedVersion;
private bool _hasChanges, _hasConflict;
private string? _statusMessage;
private int _skippedVersions;
public ChangelogPanelViewModel(IAdminContext context) : base(context)
{
GameChoices = context.Games.Select(g => new Choice(g.GameId, $"{g.GameName} ({g.GameId})")).ToList();
_selectedGame = GameChoices.FirstOrDefault(g => string.Equals(g.Value, context.DefaultGameId, StringComparison.OrdinalIgnoreCase))
?? GameChoices.FirstOrDefault();
SaveCommand = new AsyncRelayCommand(SaveAsync, () => !IsBusy && HasChanges && !HasConflict && _state is not null);
ReloadCommand = new AsyncRelayCommand(ReloadAsync, () => !IsBusy && _selectedGame is not null);
}
// ------------------------------------------------------------------ Jeu
public IReadOnlyList<Choice> GameChoices { get; }
public bool HasSeveralGames => GameChoices.Count > 1;
public Choice? SelectedGame
{
get => _selectedGame;
set
{
if (value is null || Equals(value, _selectedGame)) return;
if (HasChanges && !Context.Dialogs.Confirm("Abandonner les modifications ?",
"Les notes modifiées de ce jeu n'ont pas été enregistrées : elles seront perdues.", "Abandonner et changer de jeu"))
{
OnPropertyChanged(); // la liste de l'écran revient au jeu affiché
return;
}
_selectedGame = value;
OnPropertyChanged();
Clear(); // rien de l'ancien jeu ne reste affiché (ni enregistrable) si le chargement échoue
_ = LoadAsync();
}
}
// ------------------------------------------------------------------ Versions
public ObservableCollection<VersionEditor> Versions { get; } = new();
public VersionEditor? SelectedVersion
{
get => _selectedVersion;
set
{
// null : la liste de l'écran se vide lors d'un rechargement ; la sélection est rétablie ensuite.
if (value is not null) SetProperty(ref _selectedVersion, value);
}
}
public bool HasVersions => Versions.Count > 0;
public bool ShowNoVersions => _state is not null && Versions.Count == 0;
/// <summary>« Version publiée : 1.1 · 6 versions avec des notes ».</summary>
public string ManifestText => _state is null ? "" :
$"Version publiée : {_state.ManifestVersion} · {Versions.Count} version{(Versions.Count > 1 ? "s" : "")} avec des notes" +
(_skippedVersions > 0 ? $" ({_skippedVersions} non modifiable{(_skippedVersions > 1 ? "s" : "")} en ligne : numéro avec caractères spéciaux ou en double)" : "");
// ------------------------------------------------------------------ État
public bool HasChanges
{
get => _hasChanges;
private set
{
if (SetProperty(ref _hasChanges, value)) SaveCommand.RaiseCanExecuteChanged();
}
}
/// <summary>Le manifest a changé sur le serveur (version publiée, autre administrateur) : il faut recharger.</summary>
public bool HasConflict
{
get => _hasConflict;
private set
{
if (SetProperty(ref _hasConflict, value)) SaveCommand.RaiseCanExecuteChanged();
}
}
public string? StatusMessage
{
get => _statusMessage;
private set
{
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
}
}
public bool HasStatus => !string.IsNullOrEmpty(StatusMessage);
/// <summary>Faux pendant un chargement ou un enregistrement.</summary>
public bool IsEditable => !IsBusy;
public AsyncRelayCommand SaveCommand { get; }
public AsyncRelayCommand ReloadCommand { get; }
protected override void OnBusyChanged()
{
OnPropertyChanged(nameof(IsEditable));
SaveCommand.RaiseCanExecuteChanged();
ReloadCommand.RaiseCanExecuteChanged();
}
protected override Task OnActivatedAsync(bool firstTime) => firstTime ? LoadAsync() : Task.CompletedTask;
// ------------------------------------------------------------------ Chargement
public Task LoadAsync() => RunAsync(async () =>
{
if (_selectedGame?.Value is not { } gameId) return;
var state = await Context.Session.GetChangelogAsync(gameId);
Apply(state);
StatusMessage = null;
});
private async Task ReloadAsync()
{
if (HasChanges && !Context.Dialogs.Confirm("Abandonner les modifications ?",
"Les notes non enregistrées seront perdues : la page affichera celles du manifest publié.", "Abandonner et recharger"))
return;
await LoadAsync();
}
private void Apply(ChangelogState state)
{
var selected = _selectedVersion?.Version;
_state = state;
HasConflict = false;
Versions.Clear();
// Une version présente deux fois n'est modifiable qu'une fois (le serveur modifie la première).
var editable = state.Entries.Where(e => ChangelogLimits.IsEditableVersion(e.Version))
.DistinctBy(e => e.Version, StringComparer.Ordinal).ToList();
_skippedVersions = state.Entries.Count - editable.Count;
foreach (var entry in editable) Versions.Add(new VersionEditor(entry, Confirm, RefreshChanges));
_selectedVersion = Versions.FirstOrDefault(v => v.Version == selected) ?? Versions.FirstOrDefault();
foreach (var name in new[] { nameof(SelectedVersion), nameof(HasVersions), nameof(ShowNoVersions), nameof(ManifestText) })
OnPropertyChanged(name);
RefreshChanges();
}
// ------------------------------------------------------------------ Enregistrement
private Task SaveAsync() => RunAsync(async () =>
{
if (_state is null || _selectedGame?.Value is not { } gameId) return;
var dirty = Versions.Where(v => v.IsDirty).ToList();
// Tout est vérifié avant le premier envoi : limites, taille de chaque requête.
foreach (var version in dirty)
{
var entry = version.ToEntry();
var errors = version.LimitErrors; // limites + notes vides
if (errors.Count > 0)
{
SelectedVersion = version;
Context.Dialogs.ShowMessage($"Notes de la version {version.Version} à corriger",
string.Join("\n", errors.Take(12)), ModuleMessageKind.Warning);
return;
}
var size = JsonSerializer.SerializeToUtf8Bytes(new ChangelogUpdateRequest { BaseSha256 = _state.ManifestSha256, Entry = entry }, ApiJson.Options).Length;
if (size > ChangelogLimits.MaxEditRequestBytes)
{
SelectedVersion = version;
Context.Dialogs.ShowMessage("Notes trop longues pour l'édition en ligne",
$"Les notes de la version {version.Version} font {size / 1024} Ko (au plus {ChangelogLimits.MaxEditRequestBytes / 1024} Ko par version). " +
"Raccourcissez-les, ou modifiez notes.txt puis republiez la version.", ModuleMessageKind.Warning);
return;
}
}
var saved = new List<string>();
try
{
foreach (var version in dirty)
{
var state = await Context.Session.SaveChangelogEntryAsync(gameId, version.ToEntry(), _state.ManifestSha256);
_state = state;
if (state.Entries.FirstOrDefault(e => e.Version == version.Version) is { } published) version.Load(published);
saved.Add(version.Version);
}
StatusMessage = $"Notes enregistrées ({string.Join(", ", saved)}). Les launchers les afficheront à leur prochaine recherche de mise à jour.";
Log.Info($"Administration : notes de version modifiées ({gameId} {string.Join(", ", saved)}).");
}
catch (ApiException ex) when (ex.Status == 409 && ex.Code == ApiErrorCodes.RevisionConflict)
{
HasConflict = true;
if (saved.Count > 0) StatusMessage = $"Notes enregistrées : {string.Join(", ", saved)}.";
Context.Dialogs.ShowMessage("Manifest modifié entre-temps",
"Le manifest du jeu a changé sur le serveur (nouvelle version publiée ou autre administrateur). Rechargez la page " +
"pour partir de la version publiée (les modifications non enregistrées seront perdues : notez-les si besoin).",
ModuleMessageKind.Warning);
}
});
// ------------------------------------------------------------------ Outils
private void Clear()
{
_state = null;
HasConflict = false;
StatusMessage = null;
Versions.Clear();
_selectedVersion = null;
_skippedVersions = 0;
foreach (var name in new[] { nameof(SelectedVersion), nameof(HasVersions), nameof(ShowNoVersions), nameof(ManifestText) })
OnPropertyChanged(name);
RefreshChanges();
SaveCommand.RaiseCanExecuteChanged();
}
private void RefreshChanges() => HasChanges = Versions.Any(v => v.IsDirty);
private bool Confirm(string title, string message) => Context.Dialogs.Confirm(title, message, "Retirer", danger: true);
}
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
Module « Administration » du launcher (sans WPF) : clés de l'administrateur, connexion,
invitation, verrouillage de session, tableau de bord. Les écrans correspondants sont dans le
launcher (Views\Admin, Themes\Admin.xaml). Voir docs\ADMIN-ARCHITECTURE.md.
-->
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>GameLauncher.Modules.Admin</RootNamespace>
<AssemblyName>GameLauncher.Modules.Admin</AssemblyName>
<Platforms>AnyCPU;x64</Platforms>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\GameLauncher.Modules\GameLauncher.Modules.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="GameLauncher.Tests" />
</ItemGroup>
</Project>
@@ -0,0 +1,60 @@
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Security;
using GameLauncher.Core.Utils;
namespace GameLauncher.Modules.Admin.Services;
/// <summary>
/// Connexion ouverte : session + clé déverrouillée (en mémoire, ou ouverte dans la puce TPM). La fermer
/// déconnecte la session sur le serveur et libère la clé : il faudra de nouveau la phrase de passe (ou le PIN).
/// </summary>
public sealed class AdminConnection : IAsyncDisposable
{
private static readonly TimeSpan LogoutTimeout = TimeSpan.FromSeconds(5);
private readonly IAdminSigner _signer;
private readonly IDisposable _key;
private int _disposed;
/// <param name="key">Ce qui retient la clé (clé en mémoire, poignée de la clé TPM) : libéré à la fermeture.</param>
internal AdminConnection(LauncherApiClient client, AdminSession session, IAdminSigner signer, IDisposable key, string keyPath,
bool isTpm = false)
{
Client = client;
Session = session;
_signer = signer;
_key = key;
KeyPath = keyPath;
IsTpm = isTpm;
}
public LauncherApiClient Client { get; }
public AdminSession Session { get; }
public string KeyPath { get; }
public string Fingerprint => AdminKeys.ComputeFingerprint(_signer.PublicKey);
/// <summary>Clé conservée dans la puce de sécurité (TPM) de ce PC.</summary>
public bool IsTpm { get; }
/// <summary>Fin de session (durée maximale), à l'heure du PC.</summary>
public DateTimeOffset LocalExpiresAt => Session.ExpiresAt - Session.ClockOffset;
/// <summary>
/// Libère d'abord la clé (la déconnexion n'est pas signée : elle n'en a pas besoin), puis ferme la
/// session sur le serveur — au mieux, sans bloquer : injoignable, elle expirera d'elle-même.
/// </summary>
public async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
_key.Dispose();
using var timeout = new CancellationTokenSource(LogoutTimeout);
try
{
await Session.LogoutAsync(timeout.Token).ConfigureAwait(false);
}
catch (Exception ex)
{
Log.Warn($"Administration : déconnexion du serveur impossible ({ex.GetType().Name}) ; la session expirera d'elle-même.");
}
}
}
@@ -0,0 +1,193 @@
using System.Security.Cryptography;
using GameLauncher.ApiClient;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
namespace GameLauncher.Modules.Admin.Services;
/// <summary>Résultat de la création d'une clé (et de son enregistrement auprès du serveur avec une invitation).</summary>
/// <param name="Enrollment">Réponse du serveur ; null si aucune invitation n'a été utilisée (clé seulement) ou sans réponse.</param>
/// <param name="EnrollmentUnconfirmed">
/// Invitation envoyée mais réponse du serveur perdue (délai dépassé, coupure, erreur 5xx) : le serveur a
/// peut-être enregistré la clé et consommé l'invitation. La clé est donc CONSERVÉE (message expliquant quoi faire).
/// </param>
/// <param name="IsTpm">Clé créée dans la puce de sécurité (TPM) de ce PC ; KeyPath est alors son fichier de référence.</param>
public sealed record AdminKeyCreation(string KeyPath, string PublicKeyPath, string KeyId, string Fingerprint,
EnrollmentResponse? Enrollment, string? EnrollmentUnconfirmed = null, bool IsTpm = false);
/// <summary>Clé fichier en mémoire, vue comme une clé « libérable » (même contrat que la clé TPM).</summary>
internal sealed class FileKeySigner : IDisposableAdminSigner
{
private readonly EcdsaAdminSigner _inner;
public FileKeySigner(EcdsaAdminSigner inner) => _inner = inner;
public byte[] PublicKey => _inner.PublicKey;
public string KeyId => _inner.KeyId;
public ValueTask<byte[]> SignAsync(byte[] message, CancellationToken ct = default) => _inner.SignAsync(message, ct);
public void Dispose() => _inner.Dispose();
}
/// <summary>
/// Création d'une clé d'administrateur sur ce PC — fichier chiffré par une phrase de passe, ou clé
/// non exportable dans la puce TPM protégée par un code PIN — et, avec un code d'invitation,
/// enregistrement auprès du serveur (preuve de possession). Si le serveur REFUSE (erreur 4xx), la clé
/// est supprimée (fichiers, et clé de la puce) ; sans réponse sûre, elle est conservée.
/// </summary>
public sealed class AdminEnrollmentService
{
private readonly LauncherApiClient _client;
private readonly IAdminTpm? _tpm;
/// <param name="tpm">Puce de sécurité de ce PC (null : clés TPM indisponibles).</param>
public AdminEnrollmentService(LauncherApiClient client, IAdminTpm? tpm = null)
{
_client = client;
_tpm = tpm;
}
/// <param name="keyPath">Fichier .key (clé chiffrée) ou, avec <paramref name="useTpm"/>, fichier de référence .tpmkey.</param>
/// <param name="secret">Phrase de passe du fichier (12 caractères au moins) ou code PIN de la clé TPM (6 au moins).</param>
/// <param name="useTpm">Vrai : clé créée dans la puce de sécurité (TPM) de ce PC.</param>
public async Task<AdminKeyCreation> CreateKeyAsync(string keyPath, string secret, string? invitationCode, string? keyLabel,
CancellationToken ct = default, bool useTpm = false)
{
var minLength = useTpm ? AdminTpmKeyFile.MinPinLength : AdminKeyFile.MinPassphraseLength;
if (secret.Length < minLength)
throw new ArgumentException(useTpm
? $"Le code PIN doit contenir au moins {minLength} caractères."
: $"La phrase de passe doit contenir au moins {minLength} caractères.");
string? code = null;
if (!string.IsNullOrWhiteSpace(invitationCode))
code = InvitationCodes.Normalize(invitationCode)
?? throw new ArgumentException("Code d'invitation mal saisi : 32 caractères (lettres A à Z, chiffres 2 à 7), tirets facultatifs.");
var label = string.IsNullOrWhiteSpace(keyLabel) ? null : ApiText.CleanLine(keyLabel, ApiText.MaxKeyLabelLength)
?? throw new ArgumentException($"Nom du poste invalide ({ApiText.MaxKeyLabelLength} caractères au plus, sans caractère spécial invisible).");
var fullPath = Path.GetFullPath(keyPath);
if (AdminKeyLocator.IsReserved(fullPath))
throw new InvalidOperationException($"« {AdminKeyLocator.ReservedFileName} » est réservé à la clé de signature du launcher.");
var extension = useTpm ? AdminTpmKeyFile.Extension : AdminKeyFile.PrivateKeyExtension;
if (!fullPath.EndsWith(extension, StringComparison.OrdinalIgnoreCase))
throw new ArgumentException($"Le fichier de clé doit avoir l'extension {extension}.");
var publicPath = Path.ChangeExtension(fullPath, AdminKeyFile.PublicKeyExtension);
// Les deux types de clés partagent le même .pub : aucun des trois fichiers ne doit exister.
var otherPath = Path.ChangeExtension(fullPath, useTpm ? AdminKeyFile.PrivateKeyExtension : AdminTpmKeyFile.Extension);
if (File.Exists(fullPath) || File.Exists(publicPath) || File.Exists(otherPath))
throw new IOException($"Une clé existe déjà à cet emplacement : {Path.ChangeExtension(fullPath, null)}. Choisissez un autre nom.");
var tpm = useTpm ? _tpm ?? throw new InvalidOperationException("La puce de sécurité (TPM) n'est pas utilisable sur ce PC.") : null;
// Génération (et chiffrement, ou accès à la puce : lents) hors du fil de l'interface.
string? tpmKeyName = null;
IDisposableAdminSigner signer;
if (tpm is not null)
{
var keyName = AdminTpmKeyFile.NewKeyName();
signer = await BackgroundWork.RunAsync(() =>
{
var created = tpm.CreateKey(keyName, secret);
try
{
AdminTpmKeyFile.Save(fullPath, keyName, created.PublicKey);
return created;
}
catch
{
created.Dispose();
DeleteTpmKey(tpm, keyName);
throw;
}
}, ct).ConfigureAwait(false);
tpmKeyName = keyName;
}
else
{
signer = await BackgroundWork.RunAsync<IDisposableAdminSigner>(() =>
{
var key = AdminKeyFile.Generate();
try
{
AdminKeyFile.Save(key, fullPath, secret);
return new FileKeySigner(new EcdsaAdminSigner(key));
}
catch
{
key.Dispose();
throw;
}
}, ct).ConfigureAwait(false);
}
try
{
EnrollmentResponse? enrollment = null;
string? unconfirmed = null;
if (code is not null)
{
try
{
enrollment = await _client.EnrollAsync(code, signer, label, ct).ConfigureAwait(false);
}
catch (Exception ex) when (IsDefinitiveRefusal(ex) || ex is CryptographicException or ObjectDisposedException)
{
// Refus certain (invitation invalide, expirée, déjà utilisée…), ou signature de la preuve
// impossible (puce bloquée…) avant tout envoi : rien n'a été enregistré.
signer.Dispose();
if (tpmKeyName is not null)
{
AdminTpmKeyFile.DeleteFiles(fullPath);
DeleteTpmKey(tpm!, tpmKeyName);
}
else
{
TryDelete(fullPath);
TryDelete(publicPath);
}
throw;
}
catch (Exception ex) when (ex is ApiException or OperationCanceledException or HttpRequestException or IOException)
{
unconfirmed = $"Le serveur n'a pas confirmé l'enregistrement ({ApiErrorText.Describe(ex)}). " +
"Votre clé est conservée : communiquez son empreinte à la personne qui vous a invité. " +
"Si elle ne voit aucune clé en attente à votre nom, demandez-lui une nouvelle invitation.";
}
}
var publicKey = signer.PublicKey;
return new AdminKeyCreation(fullPath, publicPath, AdminKeys.ComputeKeyId(publicKey), AdminKeys.ComputeFingerprint(publicKey),
enrollment, unconfirmed, IsTpm: tpmKeyName is not null);
}
finally
{
signer.Dispose();
}
}
private static void DeleteTpmKey(IAdminTpm tpm, string keyName)
{
try
{
tpm.DeleteKey(keyName);
}
catch (Exception ex)
{
Core.Utils.Log.Warn($"Administration : clé {keyName} non supprimée de la puce ({ex.Message}).");
}
}
/// <summary>Le serveur a répondu par un refus (4xx), ou la requête n'a pas pu partir (horloge) : rien d'enregistré.</summary>
private static bool IsDefinitiveRefusal(Exception ex) => ex is ApiException api &&
((api.Status is >= 400 and < 500 && api.Code != "invalid_response") || api.Code == "clock_skew");
private static void TryDelete(string path)
{
try
{
File.Delete(path);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// laissé en place : sans danger (clé chiffrée, jamais enregistrée sur le serveur)
}
}
}
@@ -0,0 +1,149 @@
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Security;
namespace GameLauncher.Modules.Admin.Services;
/// <summary>Clé d'administrateur trouvée sur ce PC.</summary>
/// <param name="Path">Chemin complet du fichier .key (clé chiffrée) ou .tpmkey (référence d'une clé TPM).</param>
/// <param name="Fingerprint">Empreinte (null si inconnue : fichier .pub absent).</param>
/// <param name="IsTpm">Clé conservée dans la puce de sécurité (TPM) de ce PC : déverrouillée par un code PIN.</param>
public sealed record AdminKeyEntry(string Path, string? KeyId, string? Fingerprint, bool IsTpm = false)
{
public string Name => System.IO.Path.GetFileNameWithoutExtension(Path);
public string Detail => Fingerprint is null ? "Empreinte inconnue (fichier .pub absent)" : $"Empreinte {Fingerprint}";
/// <summary>Libellé du secret demandé à la connexion.</summary>
public string SecretLabel => IsTpm ? "Code PIN de la clé (puce TPM)" : "Phrase de passe de la clé";
}
/// <summary>
/// Recherche des clés d'administrateur : dossier %USERPROFILE%\.launcher-keys (celui de
/// tools\admin-key.bat) et fichiers déjà utilisés. La clé de SIGNATURE DU LAUNCHER, rangée dans le
/// même dossier, n'est jamais proposée : elle sert à autre chose et ne doit pas circuler.
/// </summary>
public static class AdminKeyLocator
{
/// <summary>Clé de signature des mises à jour du launcher (tools\keygen-launcher.bat).</summary>
public const string ReservedFileName = "launcher-signing.key";
private const long MaxKeyFileBytes = 16 * 1024;
public static string DefaultDirectory =>
System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".launcher-keys");
/// <summary>
/// Clé de signature du launcher, copies comprises (« launcher-signing - Copie.key »…) : ne doit jamais
/// être déverrouillée par le menu d'administration. Chemin normalisé (« launcher-signing.key. »…).
/// </summary>
public static bool IsReserved(string path)
{
string name;
try
{
name = System.IO.Path.GetFileName(System.IO.Path.GetFullPath(path)).TrimEnd('.', ' ');
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException)
{
return true; // chemin invalide : refusé
}
return name.StartsWith(System.IO.Path.GetFileNameWithoutExtension(ReservedFileName), StringComparison.OrdinalIgnoreCase);
}
/// <summary>Clés du dossier <paramref name="directory"/> puis des chemins mémorisés (sans doublon).</summary>
public static IReadOnlyList<AdminKeyEntry> Find(string directory, IEnumerable<string>? rememberedPaths = null)
{
var paths = new List<string>();
try
{
if (Directory.Exists(directory))
paths.AddRange(Directory.EnumerateFiles(directory, "*" + AdminKeyFile.PrivateKeyExtension, SearchOption.TopDirectoryOnly)
.Concat(Directory.EnumerateFiles(directory, "*" + AdminTpmKeyFile.Extension, SearchOption.TopDirectoryOnly))
.OrderBy(p => p, StringComparer.OrdinalIgnoreCase));
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// dossier illisible : seulement les chemins mémorisés
}
paths.AddRange(rememberedPaths ?? Enumerable.Empty<string>());
var result = new List<AdminKeyEntry>();
foreach (var path in paths)
{
// Describe normalise le chemin et ignore les chemins invalides (admin.json modifié à la main…).
if (Describe(path) is not { } entry) continue;
if (result.Any(e => string.Equals(e.Path, entry.Path, StringComparison.OrdinalIgnoreCase))) continue;
result.Add(entry);
}
return result;
}
/// <summary>Décrit un fichier de clé chiffré ; null s'il n'en est pas un (ou s'il est réservé).</summary>
public static AdminKeyEntry? Describe(string path)
{
try
{
var full = System.IO.Path.GetFullPath(path);
var info = new FileInfo(full);
if (!info.Exists || info.Length > MaxKeyFileBytes || IsReserved(full)) return null;
// Référence d'une clé TPM : empreinte calculée depuis la clé publique qu'elle contient
// (comparée à celle de la puce à la connexion).
if (AdminTpmKeyFile.IsTpmKeyPath(full))
{
try
{
var reference = AdminTpmKeyFile.Load(full);
return new AdminKeyEntry(full, AdminKeys.ComputeKeyId(reference.PublicKey),
AdminKeys.ComputeFingerprint(reference.PublicKey), IsTpm: true);
}
catch (System.Security.Cryptography.CryptographicException)
{
return null;
}
}
// Clé fichier : reconnue à son contenu (extension .key conseillée, mais pas exigée : admin-key.bat --out).
if (!File.ReadAllText(full).Contains("-----BEGIN ENCRYPTED PRIVATE KEY-----", StringComparison.Ordinal)) return null;
string? keyId = null, fingerprint = null;
var publicPath = System.IO.Path.ChangeExtension(full, AdminKeyFile.PublicKeyExtension);
if (File.Exists(publicPath) && new FileInfo(publicPath).Length <= MaxKeyFileBytes)
{
try
{
var spki = AdminKeys.ParsePublicKey(File.ReadAllText(publicPath));
keyId = AdminKeys.ComputeKeyId(spki);
fingerprint = AdminKeys.ComputeFingerprint(spki);
}
catch (System.Security.Cryptography.CryptographicException)
{
// .pub illisible : empreinte inconnue (la clé reste utilisable)
}
}
return new AdminKeyEntry(full, keyId, fingerprint);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException)
{
return null;
}
}
/// <summary>
/// Emplacement libre pour une nouvelle clé : admin, sinon admin-2, admin-3… Le nom est libre pour
/// les deux types de clés (.key, .tpmkey) et pour le .pub qu'ils partageraient.
/// </summary>
/// <param name="tpm">Vrai : chemin d'un fichier de référence de clé TPM (.tpmkey).</param>
public static string SuggestNewKeyPath(string directory, bool tpm = false)
{
var extension = tpm ? AdminTpmKeyFile.Extension : AdminKeyFile.PrivateKeyExtension;
for (var i = 1; i < 1000; i++)
{
var name = System.IO.Path.Combine(directory, i == 1 ? "admin" : $"admin-{i}");
if (!File.Exists(name + AdminKeyFile.PrivateKeyExtension) && !File.Exists(name + AdminTpmKeyFile.Extension) &&
!File.Exists(name + AdminKeyFile.PublicKeyExtension))
return name + extension;
}
return System.IO.Path.Combine(directory, $"admin-{Guid.NewGuid():N}{extension}");
}
}
@@ -0,0 +1,73 @@
using System.Security.Cryptography;
using GameLauncher.ApiClient;
namespace GameLauncher.Modules.Admin.Services;
/// <summary>
/// Connexion : ouvre la clé (fichier chiffré déverrouillé en arrière-plan, ou clé de la puce TPM avec
/// son code PIN), puis défi signé auprès du serveur.
/// </summary>
public sealed class AdminLoginService
{
private readonly LauncherApiClient _client;
private readonly string _clientName;
private readonly IAdminTpm? _tpm;
/// <param name="clientName">Description du poste pour le journal d'audit, ex. « GameLauncher 1.4.0 (PC-BUREAU) ».</param>
/// <param name="tpm">Puce de sécurité de ce PC (null : clés TPM indisponibles).</param>
public AdminLoginService(LauncherApiClient client, string clientName, IAdminTpm? tpm = null)
{
_client = client;
_clientName = clientName;
_tpm = tpm;
}
/// <param name="keyPath">Fichier .key (clé chiffrée) ou .tpmkey (référence d'une clé TPM).</param>
/// <param name="secret">Phrase de passe du fichier, ou code PIN de la clé TPM.</param>
public async Task<AdminConnection> LoginAsync(string keyPath, string secret, CancellationToken ct = default)
{
if (AdminKeyLocator.IsReserved(keyPath))
throw new InvalidOperationException("Ce fichier est la clé de signature du launcher : utilisez votre clé d'administrateur.");
keyPath = Path.GetFullPath(keyPath);
var isTpm = AdminTpmKeyFile.IsTpmKeyPath(keyPath);
if (string.IsNullOrEmpty(secret))
throw new ArgumentException(isTpm ? "Saisissez le code PIN de la clé." : "Saisissez la phrase de passe de la clé.");
IAdminSigner signer;
IDisposable key;
if (isTpm)
{
var tpm = _tpm ?? throw new InvalidOperationException("La puce de sécurité (TPM) n'est pas utilisable sur ce PC.");
var reference = AdminTpmKeyFile.Load(keyPath);
var opened = await BackgroundWork.RunAsync(() => tpm.OpenKey(reference.KeyName, reference.PublicKey, secret), ct)
.ConfigureAwait(false);
// Le fichier de référence ne doit pas pouvoir faire utiliser une AUTRE clé que celle annoncée
// (déjà vérifié par la puce avant le code PIN ; contrôlé de nouveau ici).
if (!opened.PublicKey.AsSpan().SequenceEqual(reference.PublicKey))
{
opened.Dispose();
throw new CryptographicException("La clé de la puce ne correspond pas à ce fichier de référence : fichier modifié ?");
}
signer = opened;
key = opened;
}
else
{
// Déchiffrement volontairement lent (PBKDF2, 600 000 itérations) : hors du fil de l'interface.
var opened = await BackgroundWork.RunAsync(() => new EcdsaAdminSigner(AdminKeyFile.Load(keyPath, secret)), ct).ConfigureAwait(false);
signer = opened;
key = opened;
}
try
{
var session = await _client.LoginAsync(signer, _clientName, ct).ConfigureAwait(false);
return new AdminConnection(_client, session, signer, key, keyPath, isTpm);
}
catch
{
key.Dispose();
throw;
}
}
}
@@ -0,0 +1,129 @@
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Core.Utils;
namespace GameLauncher.Modules.Admin.Services;
/// <summary>
/// Surveille la session ouverte :
/// • verrouillage après <see cref="LockAfter"/> sans activité dans la fenêtre d'administration ;
/// • verrouillage à la fin de la durée maximale de la session (8 h) ;
/// • tant que l'administrateur est actif, la session est prolongée sur le serveur (toutes les
/// <see cref="KeepAliveEvery"/>) ; si le serveur la refuse (expirée, révoquée), verrouillage.
/// </summary>
public sealed class AdminSessionGuard : IDisposable
{
public static readonly TimeSpan DefaultLockAfter = TimeSpan.FromMinutes(10);
public static readonly TimeSpan KeepAliveEvery = TimeSpan.FromMinutes(4);
private static readonly TimeSpan TickEvery = TimeSpan.FromSeconds(15);
private readonly AdminConnection _connection;
private readonly TimeProvider _time;
private readonly ITimer? _timer;
private DateTimeOffset _lastActivity;
private DateTimeOffset _lastKeepAlive;
private bool _refreshing;
private bool _locked;
private bool _disposed;
public AdminSessionGuard(AdminConnection connection, TimeProvider time, IUiDispatcher ui, TimeSpan? lockAfter = null,
bool startTimer = true)
{
_connection = connection;
_time = time;
LockAfter = lockAfter ?? DefaultLockAfter;
_lastActivity = _lastKeepAlive = time.GetUtcNow();
if (startTimer) _timer = time.CreateTimer(_ => ui.Post(() => _ = TickAsync()), null, TickEvery, TickEvery);
}
public TimeSpan LockAfter { get; }
/// <summary>Verrouillage demandé (le message explique pourquoi) ; attendu par <see cref="TickAsync"/>.</summary>
public Func<string, Task>? LockRequested { get; set; }
/// <summary>Le temps restant a changé (affichage).</summary>
public event Action? Changed;
/// <summary>Moment du verrouillage prévu (inactivité ou fin de session).</summary>
public DateTimeOffset LockAt
{
get
{
var idle = _lastActivity + LockAfter;
return idle < _connection.LocalExpiresAt ? idle : _connection.LocalExpiresAt;
}
}
public TimeSpan Remaining
{
get
{
var remaining = LockAt - _time.GetUtcNow();
return remaining < TimeSpan.Zero ? TimeSpan.Zero : remaining;
}
}
/// <summary>Activité de l'administrateur (souris, clavier) dans la fenêtre d'administration.</summary>
public void NotifyActivity()
{
var now = _time.GetUtcNow();
if (now - _lastActivity < TimeSpan.FromSeconds(1)) return;
_lastActivity = now;
}
/// <summary>Vérification périodique (minuterie) ; publique pour les tests.</summary>
public async Task TickAsync()
{
if (_locked || _disposed) return;
var now = _time.GetUtcNow();
if (now >= _connection.LocalExpiresAt)
{
await LockAsync("La session a atteint sa durée maximale : reconnectez-vous.");
return;
}
if (now - _lastActivity >= LockAfter)
{
await LockAsync($"Verrouillé après {LockAfter.TotalMinutes:0} minutes d'inactivité.");
return;
}
if (!_refreshing && now - _lastKeepAlive >= KeepAliveEvery && _lastActivity > _lastKeepAlive)
{
_refreshing = true;
try
{
await _connection.Session.RefreshAsync();
_lastKeepAlive = _time.GetUtcNow();
}
catch (ApiException ex) when (ex.Status == 401 && ex.Code == ApiErrorCodes.Unauthorized)
{
await LockAsync("Session expirée ou révoquée sur le serveur : reconnectez-vous.");
return;
}
catch (Exception ex)
{
// Serveur momentanément injoignable, délai dépassé… : nouvel essai au prochain passage.
// L'inactivité et la durée maximale restent contrôlées localement.
Log.Warn($"Administration : prolongation de la session impossible ({ex.GetType().Name}).");
}
finally
{
_refreshing = false;
}
}
Changed?.Invoke();
}
private async Task LockAsync(string reason)
{
if (_locked) return;
_locked = true;
if (LockRequested is { } handler) await handler(reason);
}
public void Dispose()
{
_disposed = true;
_timer?.Dispose();
}
}
@@ -0,0 +1,64 @@
using System.Text.Json;
using GameLauncher.Core.Utils;
namespace GameLauncher.Modules.Admin.Services;
/// <summary>
/// Préférences du menu d'administration sur ce PC (%AppData%\…\GameLauncher\admin.json) : chemins
/// des fichiers de clé utilisés. Aucun secret (les clés restent chiffrées dans leurs fichiers).
/// </summary>
public sealed class AdminSettings
{
public const string FileName = "admin.json";
private const int MaxRememberedKeys = 10;
public string? LastKeyPath { get; set; }
public List<string> KeyPaths { get; set; } = new();
public static AdminSettings Load(string path)
{
try
{
if (!File.Exists(path) || new FileInfo(path).Length > 64 * 1024) return new AdminSettings();
var settings = JsonSerializer.Deserialize<AdminSettings>(File.ReadAllText(path), JsonDefaults.Options) ?? new AdminSettings();
settings.KeyPaths = (settings.KeyPaths ?? new()).Where(p => !string.IsNullOrWhiteSpace(p)).Take(MaxRememberedKeys).ToList();
return settings;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
{
Log.Warn($"Préférences d'administration illisibles ({path}) : {ex.Message}");
return new AdminSettings();
}
}
/// <summary>Oublie une clé (supprimée).</summary>
public void Forget(string keyPath)
{
KeyPaths.RemoveAll(p => string.Equals(p, keyPath, StringComparison.OrdinalIgnoreCase));
if (string.Equals(LastKeyPath, keyPath, StringComparison.OrdinalIgnoreCase)) LastKeyPath = null;
}
/// <summary>Mémorise la clé utilisée (en tête de liste).</summary>
public void Remember(string keyPath)
{
var full = Path.GetFullPath(keyPath);
LastKeyPath = full;
KeyPaths.RemoveAll(p => string.Equals(p, full, StringComparison.OrdinalIgnoreCase));
KeyPaths.Insert(0, full);
if (KeyPaths.Count > MaxRememberedKeys) KeyPaths.RemoveRange(MaxRememberedKeys, KeyPaths.Count - MaxRememberedKeys);
}
public void Save(string path)
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!);
FileUtil.WriteAllTextAtomic(path, JsonSerializer.Serialize(this, JsonDefaults.Options));
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
Log.Warn($"Préférences d'administration non enregistrées ({path}) : {ex.Message}");
}
}
}
@@ -0,0 +1,35 @@
using GameLauncher.Contracts.Security;
namespace GameLauncher.Modules.Admin.Services;
/// <summary>Libellés affichés (français).</summary>
public static class AdminTexts
{
public static string Permission(string permission) => permission switch
{
AdminPermissions.AdminsManage => "Gestion des administrateurs",
AdminPermissions.AuditRead => "Journal d'audit",
AdminPermissions.GamesEdit => "Informations des jeux",
AdminPermissions.ChangelogEdit => "Notes de version",
AdminPermissions.ReleasesPublish => "Publication des versions",
_ => permission
};
public static string Role(string? role) => AdminRoles.DisplayName(role);
/// <summary>Initiales pour l'avatar : « Nicolas Deville » → « ND ».</summary>
public static string Initials(string? name)
{
var parts = (name ?? "").Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var initials = string.Concat(parts.Take(2).Select(p => char.ToUpperInvariant(p[0])));
return initials.Length == 0 ? "?" : initials;
}
/// <summary>Durée lisible : « 9 min », « moins d'une minute », « 2 h 05 ».</summary>
public static string Duration(TimeSpan duration)
{
if (duration < TimeSpan.FromMinutes(1)) return "moins d'une minute";
if (duration < TimeSpan.FromHours(1)) return $"{(int)duration.TotalMinutes} min";
return $"{(int)duration.TotalHours} h {duration.Minutes:00}";
}
}
@@ -0,0 +1,132 @@
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Security;
namespace GameLauncher.Modules.Admin.Services;
/// <summary>Clé conservée dans la puce TPM, telle que décrite par son fichier de référence.</summary>
/// <param name="KeyName">Nom de la clé dans la puce de ce PC.</param>
/// <param name="PublicKey">Clé publique (SPKI DER) attendue : comparée à celle de la puce à l'ouverture.</param>
public sealed record AdminTpmKeyReference(string KeyName, byte[] PublicKey);
/// <summary>
/// Fichier de référence d'une clé TPM (« admin.tpmkey ») : il ne contient AUCUN secret, seulement le
/// nom de la clé dans la puce et sa clé publique. Copié sur un autre PC, il ne sert à rien. Un fichier
/// .pub est écrit à côté, comme pour une clé fichier (déclaration du premier propriétaire : add-owner).
/// </summary>
public static partial class AdminTpmKeyFile
{
public const string Extension = ".tpmkey";
/// <summary>
/// Longueur minimale du code PIN. Plus courte que la phrase de passe d'un fichier (12) : le code
/// ne peut être essayé que sur la puce elle-même, qui se bloque après quelques erreurs.
/// </summary>
public const int MinPinLength = 6;
/// <summary>Préfixe des noms de clés créées par le launcher (seules ces clés peuvent être ouvertes).</summary>
public const string KeyNamePrefix = "GameLauncher.Admin.";
private const string Format = "gamelauncher-admin-tpm-key";
private const int MaxFileBytes = 16 * 1024;
[GeneratedRegex(@"\AGameLauncher\.Admin\.[0-9a-f]{32}\z")]
private static partial Regex KeyNameRegex();
public static string NewKeyName() => KeyNamePrefix + Guid.NewGuid().ToString("N");
public static bool IsValidKeyName(string? keyName) => keyName is not null && KeyNameRegex().IsMatch(keyName);
public static bool IsTpmKeyPath(string path) => path.EndsWith(Extension, StringComparison.OrdinalIgnoreCase);
/// <summary>Écrit le fichier de référence et le .pub, sans jamais écraser un fichier existant.</summary>
/// <returns>Chemin du fichier .pub.</returns>
public static string Save(string path, string keyName, byte[] publicKey)
{
if (!IsValidKeyName(keyName)) throw new ArgumentException("Nom de clé TPM invalide.", nameof(keyName));
AdminKeys.ParsePublicKey(Convert.ToBase64String(publicKey)); // P-256 attendue
if (!IsTpmKeyPath(path)) throw new ArgumentException($"Le fichier de référence doit avoir l'extension {Extension}.");
var directory = Path.GetDirectoryName(Path.GetFullPath(path));
if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory);
var json = new JsonObject
{
["format"] = Format,
["version"] = 1,
["provider"] = "Microsoft Platform Crypto Provider",
["keyName"] = keyName,
["publicKey"] = Convert.ToBase64String(publicKey)
}.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
WriteNew(path, json);
var publicPath = Path.ChangeExtension(path, AdminKeyFile.PublicKeyExtension);
try
{
WriteNew(publicPath, AdminKeys.ToPem(publicKey));
}
catch
{
TryDelete(path);
throw;
}
return publicPath;
}
/// <summary>Lit et contrôle un fichier de référence (<see cref="CryptographicException"/> s'il est invalide).</summary>
public static AdminTpmKeyReference Load(string path)
{
var info = new FileInfo(path);
if (!info.Exists) throw new FileNotFoundException($"Clé introuvable : {path}", path);
if (info.Length > MaxFileBytes) throw new CryptographicException("Fichier de clé TPM inattendu (trop volumineux).");
try
{
var root = JsonNode.Parse(File.ReadAllText(path)) as JsonObject
?? throw new CryptographicException("Fichier de clé TPM illisible.");
if (root["format"]?.GetValue<string>() != Format || root["version"]?.GetValue<int>() != 1)
throw new CryptographicException("Ce fichier n'est pas une clé TPM du launcher.");
var keyName = root["keyName"]?.GetValue<string>();
if (!IsValidKeyName(keyName)) throw new CryptographicException("Fichier de clé TPM invalide (nom de clé).");
var publicKey = AdminKeys.ParsePublicKey(root["publicKey"]?.GetValue<string>() ?? "");
return new AdminTpmKeyReference(keyName!, publicKey);
}
catch (Exception ex) when (ex is JsonException or InvalidOperationException or FormatException or ArgumentException)
{
throw new CryptographicException("Fichier de clé TPM illisible.", ex);
}
}
/// <summary>Supprime le fichier de référence et son .pub (clé refusée, création annulée, suppression demandée).</summary>
/// <returns>Les fichiers qui n'ont pas pu être supprimés (à supprimer à la main).</returns>
internal static IReadOnlyList<string> DeleteFiles(string path)
{
var left = new List<string>();
foreach (var file in new[] { path, Path.ChangeExtension(path, AdminKeyFile.PublicKeyExtension) })
if (!TryDelete(file)) left.Add(file);
return left;
}
private static void WriteNew(string path, string text)
{
using var stream = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None);
using var writer = new StreamWriter(stream);
writer.Write(text);
}
private static bool TryDelete(string path)
{
try
{
File.Delete(path);
return true;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return false; // laissé en place : sans danger (aucun secret dans ces fichiers)
}
}
}
Loaded 100 of 182 files, more files were not shown because too many files have changed in this diff. Show more