Compare commits
115
Commits
dev/1.1
...
admin/phase7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92ae5cbe63 | ||
|
|
86593a43ee | ||
|
|
8a7902e5e8 | ||
|
|
50ef1025d8 | ||
|
|
bcae86f1a1 | ||
|
|
bcde2a0dac | ||
|
|
86fef875a6 | ||
|
|
1b397ec930 | ||
|
|
c89bd62aff | ||
|
|
f556848da4 | ||
|
|
1cf9e87e82 | ||
|
|
dc09c3ecef | ||
|
|
cbcc0b4f44 | ||
|
|
d0db02d7f3 | ||
|
|
63c2e37dad | ||
|
|
78780c20f1 | ||
|
|
3b95317d4c | ||
|
|
bb165c4be0 | ||
|
|
d2cb48b901 | ||
|
|
e06ddacf04 | ||
|
|
2e4b779be5 | ||
|
|
10558a6e5f | ||
|
|
54941a1009 | ||
|
|
bd8b76015f | ||
|
|
0d3d76a68e | ||
|
|
8862047e36 | ||
|
|
25c87d672e | ||
|
|
d687414872 | ||
|
|
fb71661f17 | ||
|
|
19ccd3bf9a | ||
|
|
370443f3d4 | ||
|
|
213cb9a533 | ||
|
|
dc740e0452 | ||
|
|
72e979afd6 | ||
|
|
1d5f85a77c | ||
|
|
629fb600e9 | ||
|
|
9455277c25 | ||
|
|
f1032df74a | ||
|
|
a341bc641a | ||
|
|
9b544cbc03 | ||
|
|
001ab82fda | ||
|
|
67f4c02efc | ||
|
|
8808fe2615 | ||
|
|
bd5b9d46ca | ||
|
|
688b726e24 | ||
|
|
929b27dbeb | ||
|
|
090d8c64ec | ||
|
|
93f3ba630c | ||
|
|
89775bd73b | ||
|
|
944e47dea7 | ||
|
|
c44c9fe3ef | ||
|
|
6d44b5680a | ||
|
|
a6b2f2d6bb | ||
|
|
e888b4037b | ||
|
|
417dd78df5 | ||
|
|
c8836a9baf | ||
|
|
e887a91d9b | ||
|
|
c1ad053861 | ||
|
|
5195398943 | ||
|
|
fd90c436e7 | ||
|
|
ca194c2bf8 | ||
|
|
4b7b7bb83c | ||
|
|
270e311fb4 | ||
|
|
80d0eb93c5 | ||
|
|
6d4219228e | ||
|
|
20991ddc61 | ||
|
|
fa01962d03 | ||
|
|
53d87f3cc6 | ||
|
|
dc18e01540 | ||
|
|
723da51c65 | ||
|
|
cf3e3f5b57 | ||
|
|
7c7e12f694 | ||
|
|
950d6acc4d | ||
|
|
39dd8300e4 | ||
|
|
920d215fdd | ||
|
|
22468122e0 | ||
|
|
c2c67a63f2 | ||
|
|
d5aa875178 | ||
|
|
b9fea61e64 | ||
|
|
14a76927e1 | ||
|
|
5e68c4b16a | ||
|
|
e5b9ebf3ff | ||
|
|
fecf95ae3e | ||
|
|
3a788c18d2 | ||
|
|
dbe5d9051d | ||
|
|
ae2a5a4ab7 | ||
|
|
9aa3876a0e | ||
|
|
e8e08f792e | ||
|
|
c41bcda853 | ||
|
|
e41c3a977e | ||
|
|
f5e6c99aca | ||
|
|
35664045ce | ||
|
|
ad5d92ee8b | ||
|
|
047ec0d1db | ||
|
|
6243bd2574 | ||
|
|
7ee4d84dd2 | ||
|
|
f4ebb680ef | ||
|
|
756a76582d | ||
|
|
b5b922019c | ||
|
|
7ee8da3e67 | ||
|
|
0e8c918922 | ||
|
|
e2f2ae5817 | ||
|
|
9dda708c94 | ||
|
|
a17f0efcd7 | ||
|
|
5472522429 | ||
|
|
a443433a58 | ||
|
|
5a1985a2b5 | ||
|
|
95238a2e84 | ||
|
|
8797f9e3d3 | ||
|
|
b6d364901a | ||
|
|
434471dfc1 | ||
|
|
6512110eb0 | ||
|
|
1401d9909c | ||
|
|
8fbb12e52e | ||
|
|
7e52faf831 |
No files matched your search
@@ -61,3 +61,10 @@
|
||||
#*.PDF diff=astextplain
|
||||
#*.rtf diff=astextplain
|
||||
#*.RTF diff=astextplain
|
||||
|
||||
###############################################################################
|
||||
# Fichiers executes sur le VPS (Linux) : fins de ligne LF obligatoires
|
||||
###############################################################################
|
||||
*.sh text eol=lf
|
||||
*.service text eol=lf
|
||||
server/deploy/*.conf text eol=lf
|
||||
@@ -412,3 +412,8 @@ FodyWeavers.xsd
|
||||
# Clé PRIVÉE de signature des mises à jour du launcher : ne jamais versionner
|
||||
launcher-signing.key
|
||||
*.download
|
||||
|
||||
# Cles privees (signature du launcher, administrateurs) : jamais dans Git
|
||||
*.key
|
||||
# Donnees locales de la Launcher API (F5)
|
||||
data-dev/
|
||||
@@ -28,9 +28,43 @@ 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\sign-keyring.bat = tools\sign-keyring.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
|
||||
server\deploy\nginx-publications.conf = server\deploy\nginx-publications.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 +105,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 +170,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}
|
||||
|
||||
@@ -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, notes de version (modèle et limites), contrôle des manifests publiés (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 »), « Notes de version » (`Changelog/`) et « Publication » (`Publishing/`) (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 », « Notes de version » et « Publier une 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,25 @@ 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.
|
||||
|
||||
**Mise en ligne** : depuis le menu d'administration, page **Publier une version** (§8) : choisissez le
|
||||
`manifest.json` du dossier `--output`, puis « Publier ». Seuls les fichiers absents du serveur sont
|
||||
envoyés, l'envoi reprend après une coupure, et le manifest est mis en ligne en dernier. WinSCP reste
|
||||
possible (objects/ et l'archive d'abord, `manifest.json` en dernier), mais pas les deux en même temps.
|
||||
|
||||
### Format de `notes.txt`
|
||||
|
||||
Texte simple, à écrire comme une note de mise à jour (exemple complet : `tools/notes.txt`) :
|
||||
@@ -151,6 +175,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 +224,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 +276,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 +346,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 +380,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 +479,105 @@ 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**. Phase 5 livrée : la page **Publier une version**. Phase 6 livrée : les pages
|
||||
**Mes appareils**, **Administrateurs** et **Journal d'audit**. Phase 7 livrée : la **signature du contenu**
|
||||
servi aux joueurs (page **Signature**, voir plus bas).
|
||||
|
||||
* **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.
|
||||
* **Page « Publier une version »** (permission `releases.publish` : propriétaires et administrateurs) :
|
||||
choix du `manifest.json` produit par le .bat (ManifestBuilder), contrôle du dossier (mêmes règles
|
||||
que le serveur : chemins sûrs, archive et fichiers présents), version en ligne rappelée (avertissement
|
||||
si même numéro ou version inférieure), puis envoi : manifest, fichiers **absents du serveur**
|
||||
seulement (petits fichiers par lots, gros par morceaux), archive par morceaux de 4 Mo, chaque fichier
|
||||
vérifié (SHA-256) des deux côtés, et mise en ligne avec le manifest **en dernier** (historique des
|
||||
notes du serveur conservé). « Interrompre » garde ce qui a été envoyé : « Publier » reprend. Pas de
|
||||
verrouillage pour inactivité pendant l'envoi. Installation sur le VPS : `server/deploy/LISEZMOI.md`,
|
||||
étape 3 quater.
|
||||
* **Page « Mes appareils »** (tout administrateur) : vos PC (clés), avec leur empreinte et leur dernière
|
||||
connexion. « Ajouter un appareil » crée une invitation « nouvel appareil » (code affiché une seule
|
||||
fois) ; sur le nouveau PC, « J'ai une invitation » ; de retour ici, l'appareil en attente s'approuve
|
||||
en saisissant l'empreinte affichée sur le nouveau PC. Personne d'autre ne peut approuver un appareil
|
||||
à votre nom. Révoquer un appareil perdu : propriétaires (sinon, demandez à un propriétaire).
|
||||
* **Page « Administrateurs »** (permission `admins.manage` : propriétaires) : inviter (nom, rôle → code
|
||||
à transmettre, affiché une seule fois), approuver le premier appareil d'un invité avec l'empreinte
|
||||
qu'il vous dicte, changer un rôle, révoquer un appareil ou un compte, supprimer une invitation.
|
||||
Impossible de changer son propre rôle, de se révoquer, ou de retirer le dernier propriétaire actif.
|
||||
* **Signature du contenu** (phase 7) : chaque version mise en ligne et le catalogue portent la signature de
|
||||
l'administrateur qui les publie (automatique depuis le launcher ; une version envoyée avec WinSCP se signe
|
||||
ensuite depuis la page « Publier une version »). Les launchers ne font confiance qu'aux clés du **trousseau**
|
||||
(`keyring.json`), signé **hors ligne** par la clé racine du launcher avec `tools\sign-keyring.bat` et publié
|
||||
depuis la page **Signature** (propriétaires), qui montre aussi l'état de chaque contenu. Côté joueur,
|
||||
`"keyringUrl"` et `"contentSigning"` (`"warn"` puis `"require"`) dans `launcher.json` (intégré à l'exe) ;
|
||||
en « require », un manifest non signé, mal signé ou plus ancien que le dernier vu est refusé (le jeu installé
|
||||
reste jouable), et un catalogue refusé est ignoré. Après le retrait d'une clé compromise, relevez
|
||||
`"keyringMinSequence"` au numéro du nouveau trousseau. Mise en place : `server/deploy/LISEZMOI.md`, étape 3 quinquies.
|
||||
* **Page « Journal d'audit »** (permission `audit.read` : propriétaires et administrateurs) : connexions,
|
||||
invitations, approbations, publications, modifications et refus, les plus récents d'abord (par pages
|
||||
de 200). Filtres par catégorie, résultat et texte (nom, IP, clé…), export CSV des entrées filtrées.
|
||||
* **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 | Menu d'administration du launcher (pages « Mes appareils », « Administrateurs ») ; en ligne de commande : `tools\admin-key.bat` (`help` pour la liste) |
|
||||
| Signer le trousseau des clés autorisées à signer le contenu | Page « Signature » › « Exporter la demande… », puis glisser le fichier sur `tools\sign-keyring.bat` (clé racine, hors ligne ; nouvelles clés signalées, à vérifier de vive voix), puis « Publier le trousseau signé… » |
|
||||
| Lancer le serveur en local (F5) | Projet `GameLauncher.Server`, profil *Development* : `http://127.0.0.1:5080/api/` |
|
||||
@@ -0,0 +1,609 @@
|
||||
# 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 (page « Administrateurs ») |
|
||||
| `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.
|
||||
(Étapes 3 et 4 : « J'ai une invitation » ; étapes 1 et 5 : page « Administrateurs » du launcher,
|
||||
phase 6. `tools\admin-key.bat` reste possible en ligne de commande.)
|
||||
|
||||
**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 » (module `PublishAdminModule`, permission
|
||||
`releases.publish`) : choix du `manifest.json` produit par le `.bat`, contrôle local (manifest valide,
|
||||
archive et objets présents avec la bonne taille), puis envoi :
|
||||
|
||||
1. le manifest (par morceaux), contrôlé par le serveur ; il répond par la liste des objets **absents**
|
||||
de son dossier `objects/` ;
|
||||
2. ces objets seulement : petits fichiers **par lots** (une requête pour des centaines de fichiers),
|
||||
gros fichiers par morceaux ;
|
||||
3. l'archive ZIP **par morceaux** de 4 Mo (reprise à la position reçue) ;
|
||||
4. la mise en ligne : `manifest.json` **en dernier**, remplacé atomiquement (même règle qu'avec WinSCP).
|
||||
|
||||
**Contrôles communs** (`Contracts/Publishing/ManifestCheck`, launcher et serveur) : identifiant de jeu
|
||||
et version attendus ; archive = simple nom `.zip` à côté du manifest ; objets dans `objects/` uniquement ;
|
||||
chemins relatifs sûrs, sans doublon ; exécutable présent ; 10 000 fichiers, 128 Go d'archive au plus.
|
||||
|
||||
**Envoi** (`Storage/PublicationStore`, module `Publishing`) : toutes les requêtes sont **signées** (y
|
||||
compris chaque morceau : la signature couvre l'empreinte du corps). Seules les routes d'envoi acceptent
|
||||
8 Mo (limite propre à la route, côté Kestrel et nginx) ; le reste de l'API garde 64 Ko. Dépôt dans un
|
||||
dossier **caché** du dossier du jeu (`.publish-<id>`, 0700, refusé par nginx) : la mise en ligne ne
|
||||
consiste qu'en renommages sur le même disque. Chaque morceau doit suivre exactement ce qui a été reçu
|
||||
(409 `offset_mismatch` sinon) ; l'empreinte SHA-256 est calculée au fil des morceaux (recalculée si le
|
||||
service a redémarré) et vérifiée avant d'accepter le fichier. Espace disque vérifié (507). Une
|
||||
publication par jeu ; celle d'un autre administrateur n'est remplaçable qu'après 30 min sans envoi ;
|
||||
abandon au bout de 48 h. Limite de requêtes : les routes d'envoi de morceaux ont leur propre limite
|
||||
(`Limits:PublicationUploadsPerMinute`, 1500/min par IP, politique `publication-upload`, à la place de la
|
||||
limite générale) ; côté nginx, zone `publication_par_ip` (25/s). Chaque refus 429 du serveur annonce
|
||||
son délai (`Retry-After`).
|
||||
|
||||
**Mise en ligne** : tout est revérifié, puis contrôles **avant tout déplacement** (liens symboliques,
|
||||
droits, dossiers `objects/xx`) ; objets, archive (l'ancienne, de même nom, gardée de côté et remise en
|
||||
place si l'écriture du manifest échoue), puis manifest. Refus si les fichiers en ligne ont changé
|
||||
pendant l'envoi (empreinte du manifest **hors notes** : une correction de notes reste permise).
|
||||
Notes : celles de la version publiée, puis l'historique **du serveur** (corrections en ligne
|
||||
conservées), dans les bornes du launcher. Résultat gardé 24 h : une réponse perdue n'empêche pas de
|
||||
constater la mise en ligne. Audit : `release.start`, `release.publish`, `release.cancel`.
|
||||
Dossiers `objects/xx` manquants : créés **sans demander de bit spécial** (systemd refuse au service toute
|
||||
demande de setuid/setgid, `RestrictSUIDSGID=yes`) ; le groupe `www-data` vient du bit setgid de `objects/`,
|
||||
et les droits sont ceux du dossier du jeu, mais sans setgid (le fixer sous le masque du service le retire) :
|
||||
sans conséquence pour le launcher, qui y déplace des fichiers déjà du bon groupe, mais un fichier déposé
|
||||
avec WinSCP y prendrait le groupe `deploy`. D'où l'étape 3 quater : `objects/` et ses 256 sous-dossiers
|
||||
créés sur le VPS en `2750`.
|
||||
|
||||
**Launcher** (`Modules.Admin/Publishing/ReleaseUploader`) : les empreintes des fichiers sont vérifiées
|
||||
pendant leur lecture (un fichier modifié depuis le `.bat` est refusé) ; coupures (réseau, délai,
|
||||
429, serveur occupé, position décalée) : l'état est relu sur le serveur et l'envoi continue, après une
|
||||
pause affichée sur la page (429 : délai `Retry-After` du serveur, sinon 1 s doublée à chaque refus,
|
||||
30 s au plus) ; « Interrompre »
|
||||
garde ce qui a été reçu, « Publier » reprend. La mise en ligne elle-même ne s'interrompt pas. Pendant
|
||||
l'envoi, `IAdminContext.BeginLongOperation()` évite le verrouillage pour inactivité (verrouillage manuel,
|
||||
de Windows et durée maximale de session toujours appliqués).
|
||||
|
||||
**VPS** : bloc nginx `server/deploy/nginx-publications.conf`, écriture dans le dossier du jeu
|
||||
(`games.conf`, étape 3 ter), dossier `objects` existant avec le bit setgid (`LISEZMOI.md`, étape 3 quater).
|
||||
|
||||
## 11 bis. Administrateurs et journal d'audit (phase 6 ✅)
|
||||
|
||||
Module `AccountsAdminModule` (pages « Mes appareils » et « Administrateurs ») et module
|
||||
`AuditAdminModule` (page « Journal d'audit »), sur les routes de la phase 1. La page ne propose que ce
|
||||
que le serveur accepte ; ses refus sont affichés tels quels (le serveur reste seul juge).
|
||||
|
||||
* **Mes appareils** (tout administrateur connecté) : nouvelle route `GET v1/admins/me` (son compte, ses
|
||||
clés, ses invitations « nouvel appareil » en cours ; empreinte masquée pour une clé en attente, comme
|
||||
dans la liste complète). Ajout d'un PC : invitation `ForAdminId` = soi, puis approbation depuis ce PC
|
||||
avec l'empreinte lue sur le nouveau. Révoquer un de ses appareils reste réservé à `admins.manage`.
|
||||
Un administrateur peut supprimer une invitation « nouvel appareil » **créée par lui pour lui**
|
||||
(`DELETE v1/admins/invitations/{id}` ouverte à tout administrateur, droit revérifié dans la
|
||||
modification) ; les autres exigent `admins.manage`. `InvitationSummary.CreatedById` ajouté.
|
||||
* **Administrateurs** (`admins.manage`) : comptes en attente d'abord, puis actifs (vous en premier), les
|
||||
révoqués sur demande ; par compte : rôle (liste + « Appliquer », confirmation renforcée pour
|
||||
« Propriétaire »), révocation ; par appareil : approbation (empreinte saisie : format contrôlé localement,
|
||||
comparaison par le serveur en temps constant), révocation (sauf la clé de la session en cours). Nouvel
|
||||
appareil d'un autre compte actif : la page explique qu'il l'approuve lui-même ; compte sans appareil
|
||||
actif : restauration sur le VPS (`add-key`).
|
||||
* **Code d'invitation** : affiché une seule fois (copie dans le presse-papiers, « J'ai transmis le code »
|
||||
l'efface de l'écran) ; il n'est jamais enregistré par le launcher.
|
||||
* **Journal d'audit** (`audit.read`) : pages de 200 entrées (curseur du serveur), 5 000 au plus en
|
||||
mémoire, 500 affichées au plus (la page n'est pas virtualisée) ; filtres locaux (catégorie, résultat,
|
||||
texte ; limite des 5 000 entrées signalée) ; export CSV des entrées filtrées, toutes les cellules entre
|
||||
guillemets et celles qui commenceraient par `= + - @` préfixées d'une apostrophe (certaines valeurs
|
||||
viennent de requêtes anonymes ou de saisies : pas de formule exécutée par le tableur).
|
||||
* Après une modification, la page se recharge séparément : un échec du rechargement n'efface ni le
|
||||
résultat annoncé ni un code d'invitation (affiché avant le rechargement). Pas de nouvelle invitation
|
||||
tant qu'un code non transmis est affiché.
|
||||
Le serveur inscrit désormais le nom et le rôle de l'invitation supprimée (`admins.invite_deleted`).
|
||||
|
||||
## 12. Signature de bout en bout pour les joueurs (phase 7 ✅)
|
||||
|
||||
Sans signature, une prise de contrôle du VPS permettrait de distribuer un faux jeu. Désormais :
|
||||
|
||||
**Ancre de confiance** : la ou les clés racine intégrées à l'exe (`Security\update-signing.pub`, celles des
|
||||
mises à jour du launcher). La clé privée racine reste **hors ligne** : le menu d'administration refuse toujours
|
||||
de l'ouvrir (nom de fichier réservé, et toute clé dont l'identifiant ou la clé publique est celle d'une racine
|
||||
intégrée, quel que soit le nom du fichier) ; elle ne sert qu'à `LauncherPublisher` (mises à jour du launcher,
|
||||
`sign-keyring`).
|
||||
|
||||
**Trousseau** (`keyring.json`, `Contracts/Signing/Keyring`) : `sequence` croissante, `issuedAt`, clés
|
||||
(identifiant, clé publique SPKI, nom, autorisations `releases` et/ou `catalog`), signature racine sur le SHA-256
|
||||
du trousseau canonique (message `GameLauncher/Keyring/v1`, clés et autorisations triées). Production : page
|
||||
**Signature** (propriétaires) › « Exporter la demande » (appareils ACTIFS des comptes actifs ; `releases` si le rôle
|
||||
a `releases.publish`, `catalog` s'il a `games.edit` ; noms débarrassés des caractères invisibles, de mise en forme
|
||||
ou d'inversion du sens d'écriture, refusés aussi par `Keyring.Validate`) → `tools\sign-keyring.bat` (compare la
|
||||
demande au trousseau signé précédent `keyring-N.json` du même dossier, vérifié avec `update-signing.pub`, et
|
||||
signale les **nouvelles clés** et autorisations élargies ; la demande vient du serveur et n'est donc pas digne de
|
||||
confiance : ces empreintes se vérifient hors du serveur, avec celle que la page **Mes appareils** calcule sur le
|
||||
PC de chacun — pas celle annoncée par le serveur ; confirmation, phrase de passe ; refuse une racine absente
|
||||
d'`update-signing.pub` ; écrit `keyring-N.json`) → « Publier le trousseau signé » (le launcher vérifie la racine et
|
||||
le numéro avant l'envoi ; l'export reste possible quand le trousseau publié est refusé par ce launcher). Les noms
|
||||
sont publics. Serveur : `PUT v1/keyring` (`admins.manage`,
|
||||
requête signée) contrôle la structure et un numéro strictement croissant, écrit le fichier public (par défaut à
|
||||
côté du catalogue, 0644, atomique) et un historique ; audit `keyring.publish` (clés ajoutées/retirées). La liste
|
||||
des administrateurs expose désormais la clé publique des clés **actives** seulement (celle d'une clé en attente
|
||||
permettrait de calculer l'empreinte à dicter).
|
||||
|
||||
**Signature du contenu** (`ContentSigning`) : propriété `signature` `{ keyId, signedAt, value }` dans le JSON
|
||||
(atomique avec le contenu, ignorée par les anciens launchers), sur le message `GameLauncher/Content/v1`
|
||||
(type, identifiant du jeu ou « catalog », date à la seconde, clé, condensé). Condensés canoniques, calculés par
|
||||
chacun à partir de ce qu'il a lu (même code dans `Contracts`) :
|
||||
* manifest : jeu, version, exécutable, arguments, archive (adresse, taille, SHA-256), base des objets, fichiers
|
||||
(chemin, taille, SHA-256) triés — **pas** les notes ni la date : les notes restent corrigeables en ligne (texte
|
||||
borné, jamais exécuté) ; le launcher des joueurs calcule le même condensé depuis son modèle `GameManifest` ;
|
||||
* catalogue : tout sauf `revision`, `updatedAt` et `signature` (posés par le serveur), jeux triés par identifiant
|
||||
**tel que publié** (sans mise en minuscules) ; un catalogue dont deux jeux ne diffèrent que par la casse est
|
||||
refusé par les launchers (`HasDuplicateGames`), même signé.
|
||||
|
||||
Les membres absents ou `null` (trousseau, manifest) ne provoquent jamais d'exception : trousseau refusé, condensé
|
||||
calculé sur des valeurs vides.
|
||||
|
||||
**Production des signatures** (clé de la session ; date = heure du serveur) : à la mise en ligne d'une version
|
||||
(`ReleaseUploader`, juste avant le commit : la reprise des envois, fondée sur l'empreinte du manifest, n'est pas
|
||||
touchée ; corps `PublicationCommitRequest`), à chaque publication du catalogue (page Jeux, sur le catalogue
|
||||
contrôlé tel qu'il sera publié), et pour une version déjà en ligne (WinSCP, antérieure) : page « Publier une
|
||||
version » › « Signer la version en ligne » depuis son dossier (`POST v1/games/{id}/signature`, accepté seulement si
|
||||
le condensé en ligne est celui du dossier). Le serveur vérifie chaque signature (clé = celle de la session, date à
|
||||
± 10 min de son heure — une date future bloquerait les mises à jour suivantes chez les joueurs, signature valide)
|
||||
et peut exiger la signature (`LauncherApi:ContentSigning:Require`). Il retire toute `signature` d'un manifest reçu
|
||||
et n'y met que celle de la mise en ligne. L'empreinte « contenu en ligne inchangé pendant l'envoi » ignore les
|
||||
notes et la signature. Un nouvel essai de mise en ligne plus de 5 minutes après la signature la refait.
|
||||
|
||||
**Côté joueur** (`Core/Trust/ContentTrustService`) : `keyringUrl` et `contentSigning` lus, en Release,
|
||||
uniquement dans le `launcher.json` intégré (`off` par défaut, `warn` si `keyringUrl` est présent, `require`).
|
||||
Trousseau téléchargé au démarrage (en parallèle du catalogue), vérifié (structure, racine), mis en cache ; refusé
|
||||
s'il est plus ancien que le dernier vu, différent sous le même numéro, ou plus ancien que `keyringMinSequence`
|
||||
(launcher.json intégré, à relever après le retrait d'une clé compromise) ; hors ligne : dernier trousseau vu
|
||||
(revérifié à la lecture du cache). Aucune erreur de téléchargement (délai, réponse invalide, bogue) ne remonte à
|
||||
l'appelant : le cache est utilisé. Contenu signé par une clé absente du trousseau : nouveau téléchargement, au plus
|
||||
une fois toutes les 5 minutes. Manifest (`ManifestClient`) et catalogue (`CatalogService`, avant tout contrôle des valeurs)
|
||||
vérifiés : clé du trousseau autorisée, signature valide, **date de signature ≥ la dernière acceptée** pour ce
|
||||
contenu (un serveur compromis ne peut pas resservir une ancienne version légitime mais vulnérable). « warn » :
|
||||
journal seulement ; « require » : manifest refusé (`ContentSecurityException`, message « Mise à jour bloquée par
|
||||
sécurité », pas d'installation ni de mise à jour, le jeu installé reste jouable), catalogue ignoré (cache conservé).
|
||||
Le cache du catalogue contient le catalogue tel que publié (signature comprise) : il est revérifié à chaque
|
||||
démarrage, hors ligne, avec le dernier trousseau vu (un cache non signé ou modifié sur le disque est ignoré en
|
||||
« require »). La date de signature retenue est plafonnée à « maintenant + 1 jour » (une horloge déréglée ne bloque
|
||||
pas les versions suivantes).
|
||||
|
||||
**Limites assumées** : images du catalogue non signées ; un launcher qui n'a jamais vu de trousseau accepte tout
|
||||
trousseau signé par la racine dont le numéro atteint `keyringMinSequence` ; retirer une clé du trousseau invalide le contenu qu'elle a signé
|
||||
(la page Signature le signale : re-signer d'abord).
|
||||
|
||||
## 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) | ✅ |
|
||||
| **6** | Pages admin « Mes appareils », « Administrateurs » (invitations, approbation par empreinte, rôles, révocation) et « Journal d'audit » (filtres, export CSV) | ✅ |
|
||||
| **7** | Signature de bout en bout (trousseau signé hors ligne par la clé racine, signature des versions et du catalogue à la publication, page « Signature », vérification côté joueur en « warn » puis « require ») | ✅ |
|
||||
|
||||
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 |
|
||||
@@ -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,47 @@
|
||||
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(),
|
||||
new Modules.Publishing.PublishingModule(),
|
||||
new Modules.Signing.SigningModule()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Noms des limites de débit (voir ServerApp).</summary>
|
||||
public static class RateLimitPolicies
|
||||
{
|
||||
public const string Auth = "auth";
|
||||
public const string Enrollment = "enrollment";
|
||||
|
||||
/// <summary>
|
||||
/// Morceaux envoyés pendant une publication (manifest, objets, archive) : limite propre, plus haute,
|
||||
/// à la place de la limite générale (un envoi rapide en enchaîne plusieurs par seconde).
|
||||
/// </summary>
|
||||
public const string PublicationUpload = "publication-upload";
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
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();
|
||||
|
||||
public KeyringSettings Keyring { get; set; } = new();
|
||||
|
||||
public ContentSigningSettings ContentSigning { 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>
|
||||
/// Trousseau (phase 7) : clés autorisées à signer le contenu, signé par la clé racine HORS LIGNE puis publié
|
||||
/// depuis la page « Signature » du menu d'administration.
|
||||
/// </summary>
|
||||
public sealed class KeyringSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// Fichier public (chemin absolu .json). Vide : « keyring.json » dans le dossier du catalogue (déjà autorisé
|
||||
/// en écriture et servi par nginx), si le catalogue est configuré ; sinon publication désactivée.
|
||||
/// </summary>
|
||||
public string PublicFile { get; set; } = "";
|
||||
|
||||
/// <summary>Adresse publique (information, « keyringUrl » de launcher.json). Vide : à côté du catalogue.</summary>
|
||||
public string PublicUrl { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>Signature du contenu servi aux joueurs (phase 7).</summary>
|
||||
public sealed class ContentSigningSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// Vrai : catalogue et versions ne sont publiés que signés (refus sinon). À activer une fois le trousseau
|
||||
/// publié et les launchers en mode « require » : évite de mettre en ligne un contenu que ces launchers refuseraient.
|
||||
/// </summary>
|
||||
public bool Require { 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>
|
||||
/// Morceaux envoyés pendant une publication, par minute et par adresse IP (à la place de
|
||||
/// <see cref="RequestsPerMinute"/> pour ces routes). 1500 morceaux de 4 Mo ≈ 100 Mo/s. Ces requêtes
|
||||
/// exigent une session et le droit de publier : le corps n'est lu qu'après ces contrôles.
|
||||
/// </summary>
|
||||
public int PublicationUploadsPerMinute { get; set; } = 1500;
|
||||
|
||||
/// <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>Fichier public du trousseau en chemin absolu (null : publication désactivée), calculé par <see cref="Validate"/>.</summary>
|
||||
public string? KeyringPublicFileFullPath { get; private set; }
|
||||
|
||||
/// <summary>Adresse publique du trousseau, calculée par <see cref="Validate"/>.</summary>
|
||||
public string KeyringPublicUrl { 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();
|
||||
ValidateKeyring();
|
||||
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.PublicationUploadsPerMinute >= 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 ValidateKeyring()
|
||||
{
|
||||
KeyringPublicFileFullPath = null;
|
||||
if (!string.IsNullOrWhiteSpace(Keyring.PublicFile))
|
||||
{
|
||||
var file = Keyring.PublicFile.Trim();
|
||||
if (!Path.IsPathRooted(file) || !file.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException($"{Section}:Keyring: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}:Keyring:PublicFile : le dossier « {directory} » n'existe pas.");
|
||||
EnsureSeparatedFromData(directory, "Keyring:PublicFile");
|
||||
KeyringPublicFileFullPath = full;
|
||||
}
|
||||
else if (CatalogPublicFileFullPath is { } catalog)
|
||||
{
|
||||
KeyringPublicFileFullPath = Path.Combine(Path.GetDirectoryName(catalog)!, "keyring.json");
|
||||
}
|
||||
if (KeyringPublicFileFullPath is { } keyring && CatalogPublicFileFullPath is { } catalogFile &&
|
||||
string.Equals(keyring, catalogFile, StringComparison.Ordinal))
|
||||
throw new InvalidOperationException($"{Section}:Keyring:PublicFile doit être différent du fichier du catalogue.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Keyring.PublicUrl))
|
||||
{
|
||||
KeyringPublicUrl = new Uri(new Uri(CatalogPublicUrl), "keyring.json").AbsoluteUri;
|
||||
}
|
||||
else if (Uri.TryCreate(Keyring.PublicUrl.Trim(), UriKind.Absolute, out var url) &&
|
||||
(url.Scheme == Uri.UriSchemeHttps || (url.Scheme == Uri.UriSchemeHttp && url.IsLoopback)))
|
||||
{
|
||||
KeyringPublicUrl = url.AbsoluteUri;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException($"{Section}:Keyring:PublicUrl doit être une adresse https://.");
|
||||
}
|
||||
}
|
||||
|
||||
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,208 @@
|
||||
using System.Globalization;
|
||||
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.AspNetCore.RateLimiting;
|
||||
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, _) =>
|
||||
{
|
||||
// Délai avant la prochaine fenêtre : le launcher reprend juste à temps au lieu d'attendre à l'aveugle.
|
||||
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
|
||||
context.HttpContext.Response.Headers.RetryAfter = ((int)Math.Ceiling(retryAfter.TotalSeconds)).ToString(CultureInfo.InvariantCulture);
|
||||
return 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 =>
|
||||
IsPublicationUpload(context)
|
||||
? RateLimitPartition.GetNoLimiter("publication") // limite propre (politique PublicationUpload)
|
||||
: RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Limits.RequestsPerMinute, TimeSpan.FromMinutes(1))));
|
||||
limiter.AddPolicy(RateLimitPolicies.PublicationUpload, context =>
|
||||
RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Limits.PublicationUploadsPerMinute, 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;
|
||||
}
|
||||
|
||||
/// <summary>Route d'envoi de morceaux d'une publication (politique <see cref="RateLimitPolicies.PublicationUpload"/>).</summary>
|
||||
private static bool IsPublicationUpload(HttpContext context) =>
|
||||
context.GetEndpoint()?.Metadata.GetMetadata<EnableRateLimitingAttribute>()?.PolicyName == RateLimitPolicies.PublicationUpload;
|
||||
|
||||
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,353 @@
|
||||
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);
|
||||
|
||||
// Ouvertes à tout administrateur connecté : chacun peut voir son compte, ajouter et approuver SES
|
||||
// appareils, et supprimer les invitations « nouvel appareil » qu'il a créées pour lui-même ;
|
||||
// le reste exige admins.manage (contrôlé dans la modification elle-même, voir EnsureActor).
|
||||
api.MapGet(ApiRoutes.MyAccount, MyAccount).RequireAdmin();
|
||||
api.MapDelete("v1/admins/invitations/{invitationId:guid}", DeleteInvitation).RequireAdmin().RequireSignedRequest();
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// Compte de l'administrateur connecté : ses clés (empreinte masquée pour celles en attente, comme dans
|
||||
/// la liste complète : il la lit sur le nouvel appareil) et ses invitations « nouvel appareil » en cours.
|
||||
/// </summary>
|
||||
private static IResult MyAccount(HttpContext context, AdminStore store, TimeProvider time)
|
||||
{
|
||||
var actor = context.Admin();
|
||||
var now = time.GetUtcNow();
|
||||
var db = store.Read();
|
||||
var admin = db.FindAdmin(actor.Admin.Id) ?? throw new ApiRejection(403, ApiErrorCodes.Forbidden, "Compte introuvable.");
|
||||
return Results.Json(new MyAccount
|
||||
{
|
||||
Admin = ToSummary(admin),
|
||||
Invitations = db.Invitations.Where(i => i.ForAdminId == admin.Id && i.ExpiresAt > now).OrderBy(i => i.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}", InvitationDetails(invitation));
|
||||
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();
|
||||
var deleted = store.Update(db =>
|
||||
{
|
||||
// Sans admins.manage : seulement une invitation « nouvel appareil » créée par soi-même, pour soi-même.
|
||||
var invitation = db.Invitations.FirstOrDefault(i => i.Id == invitationId);
|
||||
var own = invitation is not null && invitation.ForAdminId == actor.Admin.Id && invitation.CreatedById == actor.Admin.Id;
|
||||
EnsureActor(db, actor, own ? AdminPermission.None : AdminPermission.Manage);
|
||||
if (invitation is null) throw ApiRejection.NotFound("Invitation introuvable.");
|
||||
db.Invitations.Remove(invitation);
|
||||
return invitation;
|
||||
});
|
||||
Audit(audit, context, "admins.invite_deleted", $"invitation {invitationId:D}", InvitationDetails(deleted));
|
||||
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 string InvitationDetails(InvitationRecord invitation) =>
|
||||
invitation.ForAdminId is null ? $"{invitation.DisplayName} ({invitation.Role})" : $"nouvel appareil pour {invitation.DisplayName}";
|
||||
|
||||
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()),
|
||||
PublicKey = k.Status == AdminStatus.Active ? Convert.ToBase64String(k.PublicKeyBytes()) : null,
|
||||
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,
|
||||
CreatedById = invitation.CreatedById,
|
||||
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,108 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
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)" : ""));
|
||||
|
||||
// Signature (phase 7) : sur le catalogue tel qu'il sera publié (après contrôle), par la clé de cette session.
|
||||
var actor = context.Admin();
|
||||
if (request.Catalog.Signature is { } signature)
|
||||
{
|
||||
ContentSignatures.RequireFromActor(signature, ContentKinds.Catalog, ContentKinds.CatalogId,
|
||||
ContentSigning.CatalogDigest(catalog), actor, time.GetUtcNow());
|
||||
catalog = catalog with { Signature = signature };
|
||||
}
|
||||
else if (options.Value.ContentSigning.Require)
|
||||
{
|
||||
return ApiResults.Error(400, ApiErrorCodes.SignatureRequired,
|
||||
"Le serveur exige un catalogue signé (LauncherApi:ContentSigning:Require) : mettez à jour le launcher.");
|
||||
}
|
||||
|
||||
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).");
|
||||
}
|
||||
|
||||
audit.Write("catalog.publish", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
$"révision {published.Revision}",
|
||||
Describe(previous, published) + (published.Signature is null ? " ; non signé" : $" ; signé (clé {published.Signature.KeyId})"));
|
||||
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,151 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Publishing;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Security;
|
||||
using GameLauncher.Server.Storage;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Modules.Publishing;
|
||||
|
||||
/// <summary>
|
||||
/// Publication d'une version depuis le menu d'administration (phase 5) : permission releases.publish,
|
||||
/// TOUTES les requêtes signées (y compris chaque morceau de fichier), audit du début, de la mise en
|
||||
/// ligne et de l'abandon. Les routes d'envoi acceptent des corps jusqu'à 8 Mo (les autres : 64 Ko).
|
||||
/// </summary>
|
||||
public sealed class PublishingModule : IApiModule
|
||||
{
|
||||
public string Name => "Publishing";
|
||||
|
||||
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) =>
|
||||
services.AddSingleton<PublicationStore>();
|
||||
|
||||
public void MapEndpoints(IEndpointRouteBuilder api)
|
||||
{
|
||||
const string permission = AdminPermissions.ReleasesPublish;
|
||||
api.MapPost(ApiRoutes.PublicationStartPattern, Start).RequirePermission(permission).RequireSignedRequest();
|
||||
api.MapGet(ApiRoutes.PublicationPattern, GetState).RequirePermission(permission);
|
||||
api.MapDelete(ApiRoutes.PublicationPattern, Cancel).RequirePermission(permission).RequireSignedRequest();
|
||||
api.MapPut(ApiRoutes.PublicationManifestPattern, Manifest).RequirePermission(permission).RequireSignedRequest()
|
||||
.WithBodyLimit(PublishingLimits.MaxUploadRequestBytes).RequireRateLimiting(RateLimitPolicies.PublicationUpload);
|
||||
api.MapPut(ApiRoutes.PublicationArchivePattern, Archive).RequirePermission(permission).RequireSignedRequest()
|
||||
.WithBodyLimit(PublishingLimits.MaxUploadRequestBytes).RequireRateLimiting(RateLimitPolicies.PublicationUpload);
|
||||
api.MapPut(ApiRoutes.PublicationObjectPattern, UploadObject).RequirePermission(permission).RequireSignedRequest()
|
||||
.WithBodyLimit(PublishingLimits.MaxUploadRequestBytes).RequireRateLimiting(RateLimitPolicies.PublicationUpload);
|
||||
api.MapPost(ApiRoutes.PublicationObjectsPattern, UploadObjects).RequirePermission(permission).RequireSignedRequest()
|
||||
.WithBodyLimit(PublishingLimits.MaxUploadRequestBytes).RequireRateLimiting(RateLimitPolicies.PublicationUpload);
|
||||
api.MapPost(ApiRoutes.PublicationCommitPattern, Commit).RequirePermission(permission).RequireSignedRequest();
|
||||
}
|
||||
|
||||
private static IResult Start(string gameId, PublicationStartRequest request, HttpContext context, PublicationStore store, AuditLog audit,
|
||||
ILogger<PublishingModule> logger)
|
||||
{
|
||||
if (!CatalogRules.IsValidGameId(gameId)) return ApiResults.BadRequest("Identifiant de jeu invalide.");
|
||||
var actor = context.Admin();
|
||||
return Guard(logger, () =>
|
||||
{
|
||||
var (state, replaced) = store.Start(gameId, request, actor.Admin.Id, actor.Admin.DisplayName);
|
||||
if (replaced is not null)
|
||||
Audit(audit, context, "release.cancel", replaced.GameId, replaced.Version, $"remplacée par la version {state.Version}");
|
||||
if (!state.Resumed)
|
||||
Audit(audit, context, "release.start", state.GameId, state.Version, $"version en ligne : {Display(state.PublishedVersion)}");
|
||||
return Results.Json(state, ApiJson.Options);
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult GetState(string id, HttpContext context, PublicationStore store, ILogger<PublishingModule> logger) =>
|
||||
Guard(logger, () => Results.Json(store.GetState(id, context.Admin().Admin.Id), ApiJson.Options));
|
||||
|
||||
private static IResult Cancel(string id, HttpContext context, PublicationStore store, AuditLog audit, ILogger<PublishingModule> logger) =>
|
||||
Guard(logger, () =>
|
||||
{
|
||||
var record = store.Cancel(id, context.Admin().Admin.Id);
|
||||
Audit(audit, context, "release.cancel", record.GameId, record.Version, "abandonnée par l'administrateur");
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
private static IResult Manifest(string id, long offset, HttpContext context, PublicationStore store, ILogger<PublishingModule> logger) =>
|
||||
Guard(logger, () => Results.Json(store.AppendManifest(id, context.Admin().Admin.Id, offset, context.CapturedBody()), ApiJson.Options));
|
||||
|
||||
private static IResult Archive(string id, long offset, HttpContext context, PublicationStore store, ILogger<PublishingModule> logger) =>
|
||||
Guard(logger, () => Results.Json(store.AppendArchive(id, context.Admin().Admin.Id, offset, context.CapturedBody()), ApiJson.Options));
|
||||
|
||||
private static IResult UploadObject(string id, string sha256, long offset, HttpContext context, PublicationStore store, ILogger<PublishingModule> logger)
|
||||
{
|
||||
if (!PublishingLimits.IsValidSha256(sha256)) return ApiResults.BadRequest("Empreinte d'objet invalide.");
|
||||
return Guard(logger, () =>
|
||||
Results.Json(store.AppendObject(id, context.Admin().Admin.Id, sha256, offset, context.CapturedBody()), ApiJson.Options));
|
||||
}
|
||||
|
||||
private static IResult UploadObjects(string id, HttpContext context, PublicationStore store, ILogger<PublishingModule> logger) =>
|
||||
Guard(logger, () => Results.Json(store.AddObjects(id, context.Admin().Admin.Id, context.CapturedBody()), ApiJson.Options));
|
||||
|
||||
private static IResult Commit(string id, HttpContext context, PublicationStore store, AuditLog audit, TimeProvider time,
|
||||
IOptions<LauncherApiOptions> options, ILogger<PublishingModule> logger) =>
|
||||
Guard(logger, () =>
|
||||
{
|
||||
// Corps facultatif : signature du contenu du manifest (phase 7). Sans corps : mise en ligne non signée.
|
||||
PublicationCommitRequest? request = null;
|
||||
var body = context.CapturedBody();
|
||||
if (body.Length > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
request = JsonSerializer.Deserialize<PublicationCommitRequest>(body, ApiJson.Options);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return ApiResults.BadRequest("Requête de mise en ligne illisible.");
|
||||
}
|
||||
}
|
||||
var signature = request?.Signature;
|
||||
if (signature is null && options.Value.ContentSigning.Require)
|
||||
return ApiResults.Error(400, ApiErrorCodes.SignatureRequired,
|
||||
"Le serveur exige une version signée (LauncherApi:ContentSigning:Require) : mettez à jour le launcher.");
|
||||
var actor = context.Admin();
|
||||
var (result, record) = store.Commit(id, actor.Admin.Id, signature,
|
||||
(gameId, digest) => ContentSignatures.RequireFromActor(signature!, ContentKinds.Manifest, gameId, digest, actor, time.GetUtcNow()));
|
||||
if (record is null) return Results.Json(result, ApiJson.Options); // déjà en ligne (réponse précédente perdue)
|
||||
Audit(audit, context, "release.publish", result.GameId, result.Version,
|
||||
$"version précédente : {Display(result.PreviousVersion)} ; {result.ObjectsAdded} fichier(s) ajouté(s)" +
|
||||
(result.ArchiveAdded ? ", archive ajoutée" : "") + $" ; manifest {result.ManifestSha256[..12]}" +
|
||||
(signature is null ? " ; non signée" : $" ; signée (clé {signature.KeyId})"));
|
||||
return Results.Json(result, ApiJson.Options);
|
||||
});
|
||||
|
||||
/// <summary>Erreurs de fichiers → 503 avec un message sans chemin (le détail va dans journalctl).</summary>
|
||||
private static IResult Guard(ILogger logger, Func<IResult> action)
|
||||
{
|
||||
try
|
||||
{
|
||||
return action();
|
||||
}
|
||||
catch (ManifestWriteRefusedException ex)
|
||||
{
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured, $"Publication refusée : {ex.Message} (server/deploy/LISEZMOI.md, étape 3 ter).");
|
||||
}
|
||||
catch (InvalidDataException ex)
|
||||
{
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured, $"Le serveur n'a pas pu lire le manifest en ligne : {ex.Message}");
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
logger.LogError(ex, "Publication d'une version");
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
|
||||
"Le serveur n'a pas pu écrire dans le dossier du jeu : vérifiez ses droits et l'espace disque (server/deploy/LISEZMOI.md, étape 3 ter).");
|
||||
}
|
||||
}
|
||||
|
||||
private static void Audit(AuditLog audit, HttpContext context, string action, string gameId, string version, string details)
|
||||
{
|
||||
var actor = context.Admin();
|
||||
audit.Write(action, AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
$"{gameId} {version}", details);
|
||||
}
|
||||
|
||||
private static string Display(string version) => string.IsNullOrEmpty(version) ? "aucune" : version;
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Security;
|
||||
using GameLauncher.Server.Storage;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Modules.Signing;
|
||||
|
||||
/// <summary>
|
||||
/// Signature du contenu servi aux joueurs (phase 7) :
|
||||
/// • GET v1/signing (admins.manage) : trousseau publié, signatures des manifests et du catalogue ;
|
||||
/// • PUT v1/keyring (admins.manage, requête signée) : publication d'un trousseau signé HORS LIGNE par la clé racine ;
|
||||
/// • GET / POST v1/games/{gameId}/signature (releases.publish) : signature de la version déjà en ligne.
|
||||
/// La signature des nouvelles versions et du catalogue se fait à leur publication (modules Publishing et Catalog).
|
||||
/// </summary>
|
||||
public sealed class SigningModule : IApiModule
|
||||
{
|
||||
public string Name => "Signing";
|
||||
|
||||
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) =>
|
||||
services.AddSingleton<KeyringStore>();
|
||||
|
||||
public void MapEndpoints(IEndpointRouteBuilder api)
|
||||
{
|
||||
api.MapGet(ApiRoutes.Signing, State).RequirePermission(AdminPermissions.AdminsManage);
|
||||
api.MapPut(ApiRoutes.Keyring, PublishKeyring).RequirePermission(AdminPermissions.AdminsManage).RequireSignedRequest();
|
||||
api.MapGet(ApiRoutes.ManifestSignaturePattern, GetManifest).RequirePermission(AdminPermissions.ReleasesPublish);
|
||||
api.MapPost(ApiRoutes.ManifestSignaturePattern, SignManifest).RequirePermission(AdminPermissions.ReleasesPublish).RequireSignedRequest();
|
||||
}
|
||||
|
||||
private static IResult State(KeyringStore keyrings, CatalogStore catalogs, AdminStore admins, IOptions<LauncherApiOptions> options,
|
||||
ILogger<SigningModule> logger)
|
||||
{
|
||||
var settings = options.Value;
|
||||
var db = admins.Read();
|
||||
var manifests = new List<ManifestSignatureState>();
|
||||
foreach (var (gameId, path) in settings.GameManifestFiles.OrderBy(g => g.Key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
try
|
||||
{
|
||||
manifests.Add(ManifestSignatures.Read(gameId, path, db));
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or JsonException or
|
||||
FormatException or InvalidOperationException)
|
||||
{
|
||||
// Manifest illisible (fichier modifié à la main…) : affiché « non signé », la page reste utilisable.
|
||||
logger.LogWarning(ex, "Signature du manifest de {GameId}", gameId);
|
||||
manifests.Add(new ManifestSignatureState { GameId = gameId });
|
||||
}
|
||||
}
|
||||
|
||||
CatalogSignatureState? catalog = null;
|
||||
if (catalogs.PublishingEnabled)
|
||||
{
|
||||
CatalogDocument? published;
|
||||
try
|
||||
{
|
||||
published = catalogs.ReadPublished();
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or JsonException)
|
||||
{
|
||||
logger.LogWarning(ex, "Signature du catalogue");
|
||||
published = null;
|
||||
}
|
||||
catalog = published is null
|
||||
? new CatalogSignatureState()
|
||||
: new CatalogSignatureState
|
||||
{
|
||||
Revision = published.Revision,
|
||||
Digest = ContentSigning.CatalogDigest(published),
|
||||
Signature = published.Signature,
|
||||
SignerName = ContentSignatures.SignerName(db, published.Signature)
|
||||
};
|
||||
}
|
||||
|
||||
KeyringDocument? keyring;
|
||||
try
|
||||
{
|
||||
keyring = keyrings.Read();
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or JsonException)
|
||||
{
|
||||
// Trousseau de référence illisible : la page propose d'en publier un nouveau (numéro choisi par le serveur au contrôle).
|
||||
logger.LogWarning(ex, "Trousseau de référence illisible");
|
||||
keyring = null;
|
||||
}
|
||||
|
||||
return Results.Json(new SigningState
|
||||
{
|
||||
Keyring = keyring,
|
||||
KeyringPublishingEnabled = keyrings.PublishingEnabled,
|
||||
KeyringPublicUrl = keyrings.PublishingEnabled ? settings.KeyringPublicUrl : null,
|
||||
RequireSignatures = settings.ContentSigning.Require,
|
||||
Manifests = manifests,
|
||||
Catalog = catalog
|
||||
}, ApiJson.Options);
|
||||
}
|
||||
|
||||
private static IResult PublishKeyring(HttpContext context, KeyringStore keyrings, AuditLog audit, ILogger<SigningModule> logger)
|
||||
{
|
||||
KeyringDocument? keyring;
|
||||
try
|
||||
{
|
||||
keyring = Keyring.Parse(context.CapturedBody());
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return ApiResults.BadRequest($"Trousseau illisible : {ex.Message}");
|
||||
}
|
||||
if (keyring is null) return ApiResults.BadRequest("Trousseau absent.");
|
||||
|
||||
KeyringDocument? previous;
|
||||
try
|
||||
{
|
||||
previous = keyrings.Publish(keyring);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
logger.LogError(ex, "Publication du trousseau");
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
|
||||
"Le serveur n'a pas pu écrire le trousseau public : vérifiez le dossier et ses droits (server/deploy/LISEZMOI.md).");
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidDataException or JsonException)
|
||||
{
|
||||
// Trousseau de référence du serveur illisible : refus (le numéro du trousseau publié ne peut pas être contrôlé).
|
||||
logger.LogError(ex, "Trousseau de référence illisible");
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
|
||||
"Le trousseau de référence du serveur est illisible : restaurez-le depuis l'historique (keyring-history/) avant de publier.");
|
||||
}
|
||||
|
||||
var actor = context.Admin();
|
||||
var added = keyring.Keys.Select(k => k.KeyId).Except(previous?.Keys.Select(k => k.KeyId) ?? Array.Empty<string>()).ToList();
|
||||
var removed = (previous?.Keys.Select(k => k.KeyId) ?? Array.Empty<string>()).Except(keyring.Keys.Select(k => k.KeyId)).ToList();
|
||||
audit.Write("keyring.publish", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
$"trousseau n° {keyring.Sequence}",
|
||||
$"{keyring.Keys.Count} clé(s), racine {keyring.Signature?.RootKeyId}" +
|
||||
(added.Count > 0 ? $" ; ajoutées : {string.Join(", ", added)}" : "") +
|
||||
(removed.Count > 0 ? $" ; retirées : {string.Join(", ", removed)}" : ""));
|
||||
return Results.Json(keyring, ApiJson.Options);
|
||||
}
|
||||
|
||||
private static IResult GetManifest(string gameId, AdminStore admins, IOptions<LauncherApiOptions> options)
|
||||
{
|
||||
var path = ManifestPath(gameId, options.Value);
|
||||
return Results.Json(ManifestSignatures.Read(gameId, path, admins.Read()), ApiJson.Options);
|
||||
}
|
||||
|
||||
private static IResult SignManifest(string gameId, ManifestSignRequest request, HttpContext context, AdminStore admins,
|
||||
AuditLog audit, TimeProvider time, IOptions<LauncherApiOptions> options, ILogger<SigningModule> logger)
|
||||
{
|
||||
var path = ManifestPath(gameId, options.Value);
|
||||
if (request.Signature is null || !PublishingSha(request.Digest)) return ApiResults.BadRequest("Signature ou condensé absent.");
|
||||
var actor = context.Admin();
|
||||
ManifestSignatureState state;
|
||||
try
|
||||
{
|
||||
state = ManifestSignatures.Sign(gameId, path, options.Value.DataDirectoryFullPath, request,
|
||||
digest => ContentSignatures.RequireFromActor(request.Signature, ContentKinds.Manifest, gameId, digest, actor, time.GetUtcNow()),
|
||||
admins.Read());
|
||||
}
|
||||
catch (ManifestWriteRefusedException ex)
|
||||
{
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured, $"Signature refusée : {ex.Message} (server/deploy/LISEZMOI.md, étape 3 ter).");
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException)
|
||||
{
|
||||
logger.LogError(ex, "Signature du manifest de {GameId}", gameId);
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
|
||||
"Le serveur n'a pas pu réécrire le manifest : vérifiez les droits du dossier du jeu (server/deploy/LISEZMOI.md, étape 3 ter).");
|
||||
}
|
||||
audit.Write("release.sign", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
$"{gameId} {state.Version}", $"version en ligne signée (contenu {state.Digest[..12]})");
|
||||
return Results.Json(state, ApiJson.Options);
|
||||
}
|
||||
|
||||
private static bool PublishingSha(string? value) => Contracts.Publishing.PublishingLimits.IsValidSha256(value);
|
||||
|
||||
private static string ManifestPath(string gameId, LauncherApiOptions options)
|
||||
{
|
||||
if (!CatalogRules.IsValidGameId(gameId)) throw ApiRejection.BadRequest("Identifiant de jeu invalide.");
|
||||
return options.GameManifestFiles.TryGetValue(gameId, out var path)
|
||||
? path
|
||||
: throw new ApiRejection(404, ApiErrorCodes.NotConfigured,
|
||||
"Jeu non configuré sur le serveur (LauncherApi:Games, voir server/deploy/LISEZMOI.md).");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Reflection;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Storage;
|
||||
|
||||
namespace GameLauncher.Server.Modules.System;
|
||||
|
||||
/// <summary>Informations publiques du serveur et état de santé.</summary>
|
||||
public sealed class SystemModule : IApiModule
|
||||
{
|
||||
public string Name => "System";
|
||||
|
||||
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) { }
|
||||
|
||||
public void MapEndpoints(IEndpointRouteBuilder api)
|
||||
{
|
||||
var version = typeof(SystemModule).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
|
||||
?? "?";
|
||||
var plus = version.IndexOf('+');
|
||||
if (plus > 0) version = version[..plus]; // sans l'empreinte du commit
|
||||
|
||||
api.MapGet(ApiRoutes.Info, (ServerIdentity server) => Results.Json(new ServerInfo
|
||||
{
|
||||
ServerId = server.ServerId,
|
||||
Origin = server.Origin,
|
||||
ApiVersion = ApiRoutes.Version,
|
||||
ServerVersion = version,
|
||||
KeyAlgorithms = new[] { AdminKeys.Algorithm }
|
||||
}, ApiJson.Options));
|
||||
|
||||
api.MapGet(ApiRoutes.Health, () => Results.Json(new HealthStatus(), ApiJson.Options));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using GameLauncher.Server.Cli;
|
||||
using GameLauncher.Server.Hosting;
|
||||
|
||||
namespace GameLauncher.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Sans commande : démarre la Launcher API (service systemd).
|
||||
/// Avec une commande (add-owner, list, revoke-admin, revoke-key, server-id, help) : outil
|
||||
/// d'administration en ligne de commande, à lancer SUR LE VPS sous le compte du service.
|
||||
/// </summary>
|
||||
public static class Program
|
||||
{
|
||||
public static async Task<int> Main(string[] args)
|
||||
{
|
||||
if (AdminCli.IsCommand(args)) return AdminCli.Run(args);
|
||||
|
||||
var app = ServerApp.Build(args);
|
||||
await app.RunAsync();
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"profiles": {
|
||||
"GameLauncher.Server": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://127.0.0.1:5080",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Storage;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Security;
|
||||
|
||||
/// <summary>Administrateur authentifié pour la requête en cours.</summary>
|
||||
public sealed record AuthenticatedAdmin(AdminSessionState Session, AdminRecord Admin, KeyRecord Key, IReadOnlyList<string> Permissions)
|
||||
{
|
||||
public bool Has(string permission) => Permissions.Contains(permission, StringComparer.Ordinal);
|
||||
|
||||
public AdminProfile ToProfile() => new()
|
||||
{
|
||||
Id = Admin.Id,
|
||||
DisplayName = Admin.DisplayName,
|
||||
Role = Admin.Role,
|
||||
Permissions = Permissions,
|
||||
KeyId = Key.KeyId,
|
||||
KeyLabel = Key.Label
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schéma « AdminSession » : jeton de session (Authorization: Bearer …). À chaque requête, la
|
||||
/// session doit être valide ET l'administrateur actif ET la clé de la session active : une
|
||||
/// révocation (même depuis l'outil en ligne de commande) prend effet immédiatement. Le rôle est relu
|
||||
/// à chaque requête : un changement de rôle s'applique aussitôt.
|
||||
/// </summary>
|
||||
public sealed class AdminSessionHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
public const string SchemeName = "AdminSession";
|
||||
public const string PermissionClaim = "perm";
|
||||
private const string ContextKey = "GameLauncher.Admin";
|
||||
|
||||
private readonly SessionStore _sessions;
|
||||
private readonly AdminStore _store;
|
||||
|
||||
public AdminSessionHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder,
|
||||
SessionStore sessions, AdminStore store)
|
||||
: base(options, logger, encoder)
|
||||
{
|
||||
_sessions = sessions;
|
||||
_store = store;
|
||||
}
|
||||
|
||||
public static AuthenticatedAdmin? Get(HttpContext context) =>
|
||||
context.Items.TryGetValue(ContextKey, out var value) ? value as AuthenticatedAdmin : null;
|
||||
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
var header = Request.Headers.Authorization.ToString();
|
||||
if (string.IsNullOrEmpty(header)) return Task.FromResult(AuthenticateResult.NoResult());
|
||||
if (!header.StartsWith("Bearer ", StringComparison.Ordinal))
|
||||
return Task.FromResult(AuthenticateResult.Fail("Schéma d'authentification inattendu."));
|
||||
|
||||
var session = _sessions.Find(header["Bearer ".Length..].Trim());
|
||||
if (session is null) return Task.FromResult(AuthenticateResult.Fail("Session absente ou expirée."));
|
||||
|
||||
var match = _store.Read().FindKey(session.KeyId);
|
||||
if (match is not { } found || found.Admin.Id != session.AdminId ||
|
||||
found.Admin.Status != AdminStatus.Active || found.Key.Status != AdminStatus.Active)
|
||||
{
|
||||
_sessions.Remove(session);
|
||||
return Task.FromResult(AuthenticateResult.Fail("Administrateur ou clé révoqué."));
|
||||
}
|
||||
|
||||
var permissions = AdminRoles.PermissionsOf(found.Admin.Role);
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, found.Admin.Id.ToString("D")),
|
||||
new(ClaimTypes.Name, found.Admin.DisplayName),
|
||||
new(ClaimTypes.Role, found.Admin.Role),
|
||||
new("key", found.Key.KeyId),
|
||||
new("sid", session.Id)
|
||||
};
|
||||
claims.AddRange(permissions.Select(p => new Claim(PermissionClaim, p)));
|
||||
|
||||
Context.Items[ContextKey] = new AuthenticatedAdmin(session, found.Admin, found.Key, permissions);
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, SchemeName));
|
||||
return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(principal, SchemeName)));
|
||||
}
|
||||
|
||||
protected override Task HandleChallengeAsync(AuthenticationProperties properties) =>
|
||||
ApiResults.WriteAsync(Context, 401, ApiErrorCodes.Unauthorized, "Authentification requise (session absente ou expirée).");
|
||||
|
||||
protected override Task HandleForbiddenAsync(AuthenticationProperties properties) =>
|
||||
ApiResults.WriteAsync(Context, 403, ApiErrorCodes.Forbidden, "Vos droits ne permettent pas cette action.");
|
||||
}
|
||||
|
||||
public static class AdminAuthorization
|
||||
{
|
||||
public static string PolicyName(string permission) => "perm:" + permission;
|
||||
|
||||
/// <summary>Route réservée aux administrateurs disposant de cette permission.</summary>
|
||||
public static TBuilder RequirePermission<TBuilder>(this TBuilder builder, string permission)
|
||||
where TBuilder : IEndpointConventionBuilder =>
|
||||
builder.RequireAuthorization(PolicyName(permission));
|
||||
|
||||
/// <summary>Route réservée à un administrateur connecté (quelles que soient ses permissions).</summary>
|
||||
public static TBuilder RequireAdmin<TBuilder>(this TBuilder builder) where TBuilder : IEndpointConventionBuilder =>
|
||||
builder.RequireAuthorization(AdminSessionHandler.SchemeName);
|
||||
|
||||
public static IServiceCollection AddAdminAuthentication(this IServiceCollection services)
|
||||
{
|
||||
services.AddAuthentication(AdminSessionHandler.SchemeName)
|
||||
.AddScheme<AuthenticationSchemeOptions, AdminSessionHandler>(AdminSessionHandler.SchemeName, null);
|
||||
services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddPolicy(AdminSessionHandler.SchemeName, p => p.RequireAuthenticatedUser());
|
||||
foreach (var permission in AdminPermissions.All)
|
||||
options.AddPolicy(PolicyName(permission),
|
||||
p => p.RequireAuthenticatedUser().RequireClaim(AdminSessionHandler.PermissionClaim, permission));
|
||||
});
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>Administrateur authentifié de la requête (les routes protégées en ont toujours un).</summary>
|
||||
public static AuthenticatedAdmin Admin(this HttpContext context) =>
|
||||
AdminSessionHandler.Get(context) ?? throw new InvalidOperationException("Route non protégée par RequireAdmin/RequirePermission.");
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Storage;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Security;
|
||||
|
||||
/// <summary>Défi de connexion : à signer avec la clé, valable quelques secondes, utilisable une seule fois.</summary>
|
||||
public sealed record LoginChallenge(string Id, string Nonce, string KeyId, DateTimeOffset ExpiresAt);
|
||||
|
||||
/// <summary>
|
||||
/// Défis de connexion SANS ÉTAT : l'identifiant du défi contient la clé visée, l'échéance et le
|
||||
/// nonce, scellés par un HMAC (secret aléatoire, en mémoire). Il n'existe donc aucune réserve
|
||||
/// partagée qu'un attaquant pourrait remplir ou vider : demander des défis ne coûte rien au serveur.
|
||||
/// Seuls les défis UTILISÉS avec succès sont mémorisés (jusqu'à leur échéance) pour refuser le rejeu ;
|
||||
/// on n'y entre qu'avec une signature valide.
|
||||
/// </summary>
|
||||
public sealed class ChallengeStore
|
||||
{
|
||||
private const int NonceLength = 32;
|
||||
private const int MacLength = 16;
|
||||
private const int PayloadLength = 8 + 8 + NonceLength; // clé (8 octets) + échéance + nonce
|
||||
private static readonly byte[] Domain = Encoding.ASCII.GetBytes("GameLauncher/Admin/Challenge/v1\n");
|
||||
|
||||
private readonly byte[] _secret = RandomNumberGenerator.GetBytes(32);
|
||||
private readonly ConcurrentDictionary<string, DateTimeOffset> _used = new(StringComparer.Ordinal);
|
||||
private readonly TimeProvider _time;
|
||||
private readonly TimeSpan _lifetime;
|
||||
private readonly int _maxUsed;
|
||||
|
||||
public ChallengeStore(IOptions<LauncherApiOptions> options, TimeProvider time)
|
||||
{
|
||||
_time = time;
|
||||
_lifetime = TimeSpan.FromSeconds(options.Value.ChallengeLifetimeSeconds);
|
||||
_maxUsed = 100_000;
|
||||
}
|
||||
|
||||
/// <summary>Longueur maximale d'un identifiant de défi (contrôle des entrées).</summary>
|
||||
public const int MaxIdLength = 96;
|
||||
|
||||
public LoginChallenge Create(string keyId)
|
||||
{
|
||||
var expires = _time.GetUtcNow() + _lifetime;
|
||||
var payload = new byte[PayloadLength];
|
||||
Convert.FromHexString(keyId).CopyTo(payload, 0);
|
||||
BinaryPrimitives.WriteInt64BigEndian(payload.AsSpan(8), expires.ToUnixTimeSeconds());
|
||||
RandomNumberGenerator.Fill(payload.AsSpan(16));
|
||||
|
||||
var id = new byte[PayloadLength + MacLength];
|
||||
payload.CopyTo(id, 0);
|
||||
Mac(payload).CopyTo(id, PayloadLength);
|
||||
return new LoginChallenge(Base64Url.Encode(id), Base64Url.Encode(payload.AsSpan(16)), keyId,
|
||||
DateTimeOffset.FromUnixTimeSeconds(expires.ToUnixTimeSeconds()));
|
||||
}
|
||||
|
||||
/// <summary>Vérifie le sceau, la clé visée et l'échéance (sans consommer le défi).</summary>
|
||||
public bool TryValidate(string challengeId, string keyId, out LoginChallenge challenge)
|
||||
{
|
||||
challenge = null!;
|
||||
if (challengeId.Length > MaxIdLength || !Base64Url.TryDecode(challengeId, out var id) ||
|
||||
id.Length != PayloadLength + MacLength || !AdminKeysHex(keyId, out var keyBytes))
|
||||
return false;
|
||||
|
||||
var payload = id.AsSpan(0, PayloadLength);
|
||||
if (!CryptographicOperations.FixedTimeEquals(Mac(payload), id.AsSpan(PayloadLength))) return false;
|
||||
if (!payload[..8].SequenceEqual(keyBytes)) return false;
|
||||
var expires = DateTimeOffset.FromUnixTimeSeconds(BinaryPrimitives.ReadInt64BigEndian(payload[8..16]));
|
||||
if (expires <= _time.GetUtcNow() || expires > _time.GetUtcNow() + _lifetime + TimeSpan.FromSeconds(1)) return false;
|
||||
|
||||
challenge = new LoginChallenge(challengeId, Base64Url.Encode(payload[16..]), keyId, expires);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Marque le défi comme utilisé (après vérification de la signature). Faux s'il l'était déjà (rejeu).</summary>
|
||||
public bool TryMarkUsed(LoginChallenge challenge)
|
||||
{
|
||||
if (_used.Count >= _maxUsed)
|
||||
{
|
||||
var now = _time.GetUtcNow();
|
||||
foreach (var (id, expires) in _used)
|
||||
if (expires <= now) _used.TryRemove(id, out _);
|
||||
if (_used.Count >= _maxUsed) throw new StoreBusyException("Trop de connexions récentes.");
|
||||
}
|
||||
return _used.TryAdd(challenge.Id, challenge.ExpiresAt);
|
||||
}
|
||||
|
||||
private byte[] Mac(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
var data = new byte[Domain.Length + payload.Length];
|
||||
Domain.CopyTo(data, 0);
|
||||
payload.CopyTo(data.AsSpan(Domain.Length));
|
||||
return HMACSHA256.HashData(_secret, data)[..MacLength];
|
||||
}
|
||||
|
||||
private static bool AdminKeysHex(string keyId, out byte[] bytes)
|
||||
{
|
||||
bytes = Array.Empty<byte>();
|
||||
if (!Contracts.Security.AdminKeys.IsValidKeyId(keyId)) return false;
|
||||
bytes = Convert.FromHexString(keyId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Storage;
|
||||
|
||||
namespace GameLauncher.Server.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Contrôle, par le serveur, des signatures de contenu envoyées par le menu d'administration (phase 7). Le
|
||||
/// serveur n'est pas l'ancre de confiance (ce sont les launchers des joueurs, avec le trousseau) : ce contrôle
|
||||
/// évite seulement de mettre en ligne une signature que les launchers refuseraient, et garantit que la clé qui
|
||||
/// signe est celle de la session (rien n'est signé au nom d'un autre).
|
||||
/// </summary>
|
||||
public static class ContentSignatures
|
||||
{
|
||||
/// <exception cref="ApiRejection">400 : signature d'une autre clé, date hors tolérance, ou signature invalide.</exception>
|
||||
public static void RequireFromActor(ContentSignature signature, string kind, string id, string digest,
|
||||
AuthenticatedAdmin actor, DateTimeOffset now)
|
||||
{
|
||||
if (!string.Equals(signature.KeyId, actor.Key.KeyId, StringComparison.Ordinal))
|
||||
throw new ApiRejection(400, ApiErrorCodes.SignatureInvalid, "La signature du contenu doit être faite par la clé de cette session.");
|
||||
if (signature.SignedAt < now - ContentSigning.MaxSignedAtSkew || signature.SignedAt > now + ContentSigning.MaxSignedAtSkew)
|
||||
throw new ApiRejection(400, ApiErrorCodes.ClockSkew,
|
||||
"Date de signature du contenu trop éloignée de l'heure du serveur : vérifiez l'heure de Windows, puis recommencez.");
|
||||
if (!ContentSigning.VerifyWithKey(kind, id, digest, signature, actor.Key.PublicKeyBytes()))
|
||||
throw new ApiRejection(400, ApiErrorCodes.SignatureInvalid,
|
||||
"Signature du contenu invalide : le contenu signé ne correspond pas à celui envoyé.");
|
||||
}
|
||||
|
||||
/// <summary>« Nicolas (PC fixe) » pour une clé connue du serveur, sinon null.</summary>
|
||||
public static string? SignerName(AdminDatabase db, ContentSignature? signature)
|
||||
{
|
||||
if (signature is null || db.FindKey(signature.KeyId) is not { } found) return null;
|
||||
return string.IsNullOrWhiteSpace(found.Key.Label) ? found.Admin.DisplayName : $"{found.Admin.DisplayName} ({found.Key.Label})";
|
||||
}
|
||||
|
||||
/// <summary>Signature présente dans un manifest publié (null : absente ou illisible).</summary>
|
||||
public static ContentSignature? ReadSignature(JsonObject manifest)
|
||||
{
|
||||
if (PublishedFiles.Property(manifest, "signature") is not JsonObject node) return null;
|
||||
try
|
||||
{
|
||||
return node.Deserialize<ContentSignature>(ApiJson.Options);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,127 @@
|
||||
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))
|
||||
{
|
||||
// Routes d'envoi de fichiers (publication) : limite propre, plus grande que celle des autres requêtes.
|
||||
if (context.GetEndpoint()?.Metadata.GetMetadata<RequestBodyLimit>() is { } limit &&
|
||||
context.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>() is { IsReadOnly: false } feature)
|
||||
feature.MaxRequestBodySize = limit.MaxBytes;
|
||||
|
||||
byte[] body;
|
||||
var maxBody = context.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>()?.MaxRequestBodySize;
|
||||
if (context.Request.ContentLength is { } announced && maxBody is { } max && announced > max)
|
||||
throw new BadHttpRequestException("Requête trop volumineuse.", StatusCodes.Status413PayloadTooLarge);
|
||||
if (context.Request.ContentLength is { } length and > 0 and <= int.MaxValue && maxBody is not null)
|
||||
{
|
||||
// Taille annoncée (bornée par Kestrel) : lecture directe, sans copie intermédiaire.
|
||||
body = new byte[length];
|
||||
await context.Request.Body.ReadExactlyAsync(body, context.RequestAborted);
|
||||
if (await context.Request.Body.ReadAsync(new byte[1], context.RequestAborted) != 0)
|
||||
throw new BadHttpRequestException("Corps plus long que annoncé.");
|
||||
}
|
||||
else
|
||||
{
|
||||
using var buffer = new MemoryStream();
|
||||
await context.Request.Body.CopyToAsync(buffer, context.RequestAborted);
|
||||
body = buffer.ToArray();
|
||||
}
|
||||
context.Items[BodyKey] = body;
|
||||
context.Request.Body = new MemoryStream(body, writable: false);
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
/// <summary>Corps de la requête (déjà lu et vérifié par la signature) : routes qui reçoivent des données brutes.</summary>
|
||||
public static byte[] CapturedBody(this HttpContext context) =>
|
||||
context.Items.TryGetValue(BodyKey, out var captured) && captured is byte[] bytes ? bytes : Array.Empty<byte>();
|
||||
|
||||
/// <summary>La route accepte un corps plus grand que la limite générale (envoi de fichiers).</summary>
|
||||
public static TBuilder WithBodyLimit<TBuilder>(this TBuilder builder, long maxBytes) where TBuilder : IEndpointConventionBuilder =>
|
||||
builder.WithMetadata(new RequestBodyLimit(maxBytes));
|
||||
|
||||
/// <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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Taille maximale du corps d'une route (métadonnée lue par <see cref="SignedRequests.UseRequestBodyCapture"/>).</summary>
|
||||
public sealed record RequestBodyLimit(long MaxBytes);
|
||||
@@ -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,149 @@
|
||||
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 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>Catalogue publié tel qu'écrit (sans normalisation : signature comprise), ou null s'il n'a jamais été publié.</summary>
|
||||
public CatalogDocument? ReadPublished()
|
||||
{
|
||||
if (!File.Exists(_path)) return null;
|
||||
return JsonSerializer.Deserialize<CatalogDocument>(File.ReadAllText(_path), Json)
|
||||
?? throw new InvalidDataException($"{FileName} est vide.");
|
||||
}
|
||||
|
||||
/// <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) => PublicFileWriter.Write(_publicFile!, json, "catalogue public");
|
||||
|
||||
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,165 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
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:<id>: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 = PublishedFiles.MaxManifestBytes;
|
||||
|
||||
/// <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 (PublishedFiles.LockGame(_dataDirectory, key))
|
||||
{
|
||||
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(PublishedFiles.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, PublishedFiles.ManifestJson)!.AsObject();
|
||||
foreach (var name in EditableFields) PublishedFiles.SetProperty(replacement, name, edited[name]?.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 newBytes = PublishedFiles.Serialize(root);
|
||||
PublishedFiles.Backup(_dataDirectory, key, bytes);
|
||||
PublishedFiles.WriteManifest(path, newBytes, baseSha256, Conflict);
|
||||
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 PublishedFiles.NotConfigured(gameId, "Modification des notes de version");
|
||||
}
|
||||
|
||||
private static byte[] ReadManifest(string path) =>
|
||||
PublishedFiles.ReadManifest(path) ?? throw ApiRejection.NotFound("Aucun manifest publié pour ce jeu (publiez d'abord une version).");
|
||||
|
||||
internal static JsonObject Parse(byte[] bytes, string gameId)
|
||||
{
|
||||
var root = PublishedFiles.ParseObject(PublishedFiles.StripBom(bytes), "Manifest");
|
||||
|
||||
// Garde-fou de configuration : le fichier configuré doit bien être celui de ce jeu.
|
||||
var manifestGameId = PublishedFiles.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 = PublishedFiles.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>
|
||||
internal static List<ChangelogEntry?> Entries(JsonArray? changelog)
|
||||
{
|
||||
try
|
||||
{
|
||||
return changelog?.Deserialize<List<ChangelogEntry?>>(PublishedFiles.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>
|
||||
internal static JsonArray? ChangelogArray(JsonObject root) => PublishedFiles.Property(root, "changelog") as JsonArray;
|
||||
|
||||
public static string Sha256(byte[] bytes) => PublishedFiles.Sha256(bytes);
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Trousseau publié (phase 7).
|
||||
/// • Référence : keyring.json dans le dossier des données privées (+ historique).
|
||||
/// • Publication : copie dans le fichier PUBLIC servi par nginx (LauncherApi:Keyring:PublicFile, par défaut à côté
|
||||
/// du catalogue), écrite atomiquement, lisible par tous.
|
||||
/// • Le serveur ne détient pas la clé racine et ne vérifie que la structure et le numéro (toujours croissant) :
|
||||
/// c'est le launcher qui vérifie la signature racine (avant l'envoi dans le menu d'administration, puis chez
|
||||
/// chaque joueur). Un trousseau falsifié serait refusé par tous les launchers.
|
||||
/// </summary>
|
||||
public sealed class KeyringStore
|
||||
{
|
||||
public const string FileName = "keyring.json";
|
||||
private const string HistoryFolder = "keyring-history";
|
||||
private const int HistoryLength = 20;
|
||||
private static readonly TimeSpan LockTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
private readonly string _path;
|
||||
private readonly string _lockPath;
|
||||
private readonly string _historyDirectory;
|
||||
private readonly string? _publicFile;
|
||||
private readonly object _writeLock = new();
|
||||
|
||||
public KeyringStore(IOptions<LauncherApiOptions> options)
|
||||
: this(options.Value.DataDirectoryFullPath, options.Value.KeyringPublicFileFullPath) { }
|
||||
|
||||
public KeyringStore(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>Trousseau publié, ou null.</summary>
|
||||
public KeyringDocument? Read()
|
||||
{
|
||||
if (!File.Exists(_path)) return null;
|
||||
return Keyring.Parse(File.ReadAllBytes(_path)) ?? throw new InvalidDataException($"{FileName} est vide.");
|
||||
}
|
||||
|
||||
/// <summary>Publie un trousseau signé (structure contrôlée, numéro supérieur au trousseau actuel).</summary>
|
||||
/// <returns>Le trousseau précédent (null : premier).</returns>
|
||||
public KeyringDocument? Publish(KeyringDocument keyring)
|
||||
{
|
||||
if (_publicFile is null)
|
||||
throw new ApiRejection(503, ApiErrorCodes.NotConfigured,
|
||||
"Publication du trousseau non configurée (LauncherApi:Keyring:PublicFile, ou LauncherApi:Catalog:PublicFile).");
|
||||
var errors = new List<string>();
|
||||
if (!Keyring.Validate(keyring, errors)) throw ApiRejection.BadRequest(string.Join(" ", errors.Take(5)));
|
||||
if (keyring.Signature is null || string.IsNullOrEmpty(keyring.Signature.Value))
|
||||
throw ApiRejection.BadRequest("Trousseau non signé : signez-le d'abord avec la clé racine (tools\\sign-keyring.bat).");
|
||||
|
||||
lock (_writeLock)
|
||||
using (SecureFiles.AcquireLock(_lockPath, LockTimeout))
|
||||
{
|
||||
var current = Read();
|
||||
if (current is not null && keyring.Sequence <= current.Sequence)
|
||||
throw ApiRejection.Conflict(
|
||||
$"Trousseau n° {keyring.Sequence} refusé : le trousseau publié porte déjà le n° {current.Sequence} " +
|
||||
"(exportez une nouvelle demande, qui prend le numéro suivant).", ApiErrorCodes.RevisionConflict);
|
||||
var json = Keyring.Serialize(keyring);
|
||||
if (System.Text.Encoding.UTF8.GetByteCount(json) > Keyring.MaxBytes)
|
||||
throw ApiRejection.BadRequest($"Trousseau trop volumineux (au plus {Keyring.MaxBytes / 1024} Ko).");
|
||||
|
||||
// Public d'abord : en cas d'échec, la référence privée (qui porte le numéro) reste l'ancienne.
|
||||
PublicFileWriter.Write(_publicFile, json, "trousseau public");
|
||||
SecureFiles.WriteAllTextAtomic(_path, json, keepBackup: true);
|
||||
WriteHistory(keyring.Sequence, json);
|
||||
return current;
|
||||
}
|
||||
}
|
||||
|
||||
private void WriteHistory(long sequence, string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
SecureFiles.EnsureDirectory(_historyDirectory);
|
||||
SecureFiles.WriteAllTextAtomic(Path.Combine(_historyDirectory, $"keyring-{sequence:D6}.json"), json, keepBackup: false);
|
||||
foreach (var old in Directory.GetFiles(_historyDirectory, "keyring-*.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,68 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Security;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Signature du manifest déjà en ligne d'un jeu (version publiée avant la phase 7, ou avec WinSCP) : le launcher
|
||||
/// envoie la signature du contenu de SON manifest.json (dossier de publication) ; le serveur ne l'ajoute que si la
|
||||
/// version en ligne a exactement ce contenu, puis réécrit le manifest atomiquement (mêmes règles que les notes).
|
||||
/// </summary>
|
||||
public static class ManifestSignatures
|
||||
{
|
||||
public static ManifestSignatureState Read(string gameId, string manifestPath, AdminDatabase admins)
|
||||
{
|
||||
if (PublishedFiles.ReadManifest(manifestPath) is not { } bytes) return new ManifestSignatureState { GameId = gameId };
|
||||
var root = PublishedFiles.ParseObject(PublishedFiles.StripBom(bytes), "Manifest");
|
||||
var signature = ContentSignatures.ReadSignature(root);
|
||||
return new ManifestSignatureState
|
||||
{
|
||||
GameId = gameId,
|
||||
Version = PublishedFiles.StringValue(root, "version") ?? "",
|
||||
Digest = Digest(bytes),
|
||||
Signature = signature,
|
||||
SignerName = ContentSignatures.SignerName(admins, signature)
|
||||
};
|
||||
}
|
||||
|
||||
/// <param name="verify">Contrôle de la signature sur le condensé de la version en ligne (lève une ApiRejection).</param>
|
||||
public static ManifestSignatureState Sign(string gameId, string manifestPath, string dataDirectory, ManifestSignRequest request,
|
||||
Action<string> verify, AdminDatabase admins)
|
||||
{
|
||||
using (PublishedFiles.LockGame(dataDirectory, gameId))
|
||||
{
|
||||
var bytes = PublishedFiles.ReadManifest(manifestPath)
|
||||
?? throw ApiRejection.NotFound("Aucune version en ligne pour ce jeu.");
|
||||
var digest = Digest(bytes);
|
||||
if (!string.Equals(digest, request.Digest, StringComparison.OrdinalIgnoreCase))
|
||||
throw ApiRejection.Conflict(
|
||||
"La version en ligne ne correspond pas au manifest.json choisi (fichiers, archive ou exécutable différents) : " +
|
||||
"choisissez le dossier de publication de la version en ligne.", ApiErrorCodes.RevisionConflict);
|
||||
verify(digest);
|
||||
|
||||
var root = PublishedFiles.ParseObject(PublishedFiles.StripBom(bytes), "Manifest");
|
||||
PublishedFiles.SetProperty(root, "signature", JsonSerializer.SerializeToNode(request.Signature, ApiJson.Options));
|
||||
var newBytes = PublishedFiles.Serialize(root);
|
||||
PublishedFiles.Backup(dataDirectory, gameId, bytes);
|
||||
PublishedFiles.WriteManifest(manifestPath, newBytes, PublishedFiles.Sha256(bytes),
|
||||
() => ApiRejection.Conflict("Le manifest a été modifié pendant la signature : recommencez.", ApiErrorCodes.RevisionConflict));
|
||||
return Read(gameId, manifestPath, admins);
|
||||
}
|
||||
}
|
||||
|
||||
private static string Digest(byte[] bytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
return ContentSigning.ManifestDigest(PublishedFiles.StripBom(bytes));
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new InvalidDataException($"Manifest en ligne illisible : {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Text;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Fichier PUBLIC servi par nginx (catalogue, trousseau) : écrit atomiquement (fichier temporaire caché du
|
||||
/// même dossier, puis renommage), lisible par tous (0644), modifiable par le service seul.
|
||||
/// </summary>
|
||||
internal static class PublicFileWriter
|
||||
{
|
||||
public const UnixFileMode Mode =
|
||||
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead;
|
||||
|
||||
/// <param name="what">Nom du fichier dans les messages (« catalogue public »…).</param>
|
||||
/// <exception cref="IOException">Écriture impossible (le fichier précédent est intact).</exception>
|
||||
public static void Write(string path, string text, string what)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(path)!;
|
||||
var temp = Path.Combine(directory, $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp");
|
||||
try
|
||||
{
|
||||
var options = new FileStreamOptions { Mode = FileMode.CreateNew, Access = FileAccess.Write, Share = FileShare.None };
|
||||
if (!OperatingSystem.IsWindows()) options.UnixCreateMode = Mode;
|
||||
using (var stream = new FileStream(temp, options))
|
||||
{
|
||||
stream.Write(new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(text));
|
||||
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, Mode);
|
||||
File.Move(temp, path, 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 {what} impossible ({path}) : {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,850 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Publishing;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Publication d'une version depuis le menu d'administration (phase 5).
|
||||
/// • Dépôt : dossier CACHÉ « .publish-<id> » dans le dossier du jeu (même disque : la mise en ligne
|
||||
/// finale n'est qu'une suite de renommages ; nginx refuse les chemins commençant par un point), droits
|
||||
/// 0700 ; description (version, plan) dans les données privées.
|
||||
/// • Envoi : manifest, puis objets absents du serveur (par lots, ou par morceaux pour les gros), puis
|
||||
/// archive par morceaux ; chaque fichier est reçu à la suite (position vérifiée : reprise possible
|
||||
/// après une coupure) puis vérifié (SHA-256) avant d'être accepté.
|
||||
/// • Mise en ligne (commit) : tout est revérifié AVANT de déplacer quoi que ce soit ; objets puis archive
|
||||
/// installés, manifest en DERNIER (comme avec WinSCP), historique des notes du serveur conservé ; si
|
||||
/// l'écriture du manifest échoue, l'archive remplacée est remise en place. Jamais si les fichiers en
|
||||
/// ligne ont changé pendant l'envoi (autre publication, WinSCP).
|
||||
/// • Une publication par jeu ; abandonnée au bout de 48 h sans envoi ; résultat conservé 24 h (une
|
||||
/// réponse perdue n'empêche pas de constater que la version est en ligne).
|
||||
/// </summary>
|
||||
public sealed class PublicationStore
|
||||
{
|
||||
private const string RecordsFolder = "publications";
|
||||
private const string StagingPrefix = ".publish-";
|
||||
private const string DoneSuffix = ".done";
|
||||
private const long FreeSpaceMargin = 256L * 1024 * 1024;
|
||||
private static readonly TimeSpan DoneLifetime = TimeSpan.FromHours(24);
|
||||
private static readonly TimeSpan CleanUpEvery = TimeSpan.FromMinutes(10);
|
||||
private const UnixFileMode PrivateDirectory = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute;
|
||||
private const UnixFileMode PrivateFile = UnixFileMode.UserRead | UnixFileMode.UserWrite;
|
||||
|
||||
private readonly IReadOnlyDictionary<string, string> _manifests;
|
||||
private readonly string _dataDirectory;
|
||||
private readonly string _recordsDirectory;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly object _startLock = new();
|
||||
private readonly ConcurrentDictionary<string, object> _locks = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, HashProgress> _hashes = new(StringComparer.Ordinal);
|
||||
private DateTimeOffset _lastCleanUp = DateTimeOffset.MinValue;
|
||||
|
||||
public PublicationStore(IOptions<LauncherApiOptions> options, TimeProvider time)
|
||||
: this(options.Value.GameManifestFiles, options.Value.DataDirectoryFullPath, time) { }
|
||||
|
||||
public PublicationStore(IReadOnlyDictionary<string, string> manifests, string dataDirectory, TimeProvider time)
|
||||
{
|
||||
_manifests = manifests;
|
||||
_dataDirectory = dataDirectory;
|
||||
_recordsDirectory = Path.Combine(dataDirectory, RecordsFolder);
|
||||
_time = time;
|
||||
}
|
||||
|
||||
// ================================================================== Début, état, abandon
|
||||
|
||||
/// <summary>
|
||||
/// Début d'une publication, ou reprise si le même manifest était en cours d'envoi. Une publication
|
||||
/// d'un autre administrateur, active depuis moins de 30 min, n'est pas remplacée (409).
|
||||
/// </summary>
|
||||
/// <returns>L'état, et la publication remplacée (audit) le cas échéant.</returns>
|
||||
public (PublicationState State, PublicationRecord? Replaced) Start(string gameId, PublicationStartRequest request, Guid adminId, string adminName)
|
||||
{
|
||||
var (manifestPath, key) = Locate(gameId);
|
||||
if (!ManifestCheck.IsValidVersion(request.Version)) throw ApiRejection.BadRequest("Numéro de version invalide.");
|
||||
if (!PublishingLimits.IsValidSha256(request.ManifestSha256)) throw ApiRejection.BadRequest("Empreinte du manifest invalide.");
|
||||
if (request.ManifestSize is <= 0 or > PublishingLimits.MaxManifestBytes) throw ApiRejection.BadRequest("Taille du manifest invalide.");
|
||||
|
||||
lock (_startLock)
|
||||
{
|
||||
CleanUp(force: true);
|
||||
PublicationRecord? replaced = null;
|
||||
foreach (var existing in Records().Where(r => string.Equals(r.GameId, key, StringComparison.OrdinalIgnoreCase)).ToList())
|
||||
{
|
||||
if (existing.AdminId == adminId && existing.Version == request.Version &&
|
||||
string.Equals(existing.ManifestSha256, request.ManifestSha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Touch(existing.Id);
|
||||
return (Lock(existing.Id, () => State(Current(existing.Id), resumed: true)), null);
|
||||
}
|
||||
var idle = _time.GetUtcNow() - LastActivity(existing.Id);
|
||||
if (existing.AdminId != adminId && idle < PublishingLimits.TakeoverAfter)
|
||||
throw ApiRejection.Conflict(
|
||||
$"{existing.AdminName} publie la version {existing.Version} de ce jeu (dernier envoi il y a " +
|
||||
$"{Math.Max(1, (int)idle.TotalMinutes)} min). Réessayez plus tard.", ApiErrorCodes.PublicationInProgress);
|
||||
Lock(existing.Id, () =>
|
||||
{
|
||||
Delete(existing);
|
||||
return 0;
|
||||
});
|
||||
replaced = existing;
|
||||
}
|
||||
|
||||
var current = PublishedFiles.ReadManifest(manifestPath);
|
||||
var currentRoot = current is null ? null : ChangelogStore.Parse(current, key);
|
||||
var record = new PublicationRecord
|
||||
{
|
||||
Id = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant(),
|
||||
GameId = key,
|
||||
Version = request.Version,
|
||||
ManifestSha256 = request.ManifestSha256.ToLowerInvariant(),
|
||||
ManifestSize = request.ManifestSize,
|
||||
AdminId = adminId,
|
||||
AdminName = adminName,
|
||||
CreatedAt = _time.GetUtcNow(),
|
||||
PublishedVersion = currentRoot is null ? "" : PublishedFiles.StringValue(currentRoot, "version") ?? "",
|
||||
PublishedContentSha256 = OnlineContentHash(currentRoot)
|
||||
};
|
||||
CreateStaging(record);
|
||||
Save(record);
|
||||
return (Lock(record.Id, () => State(record, resumed: false)), replaced);
|
||||
}
|
||||
}
|
||||
|
||||
public PublicationState GetState(string id, Guid adminId)
|
||||
{
|
||||
Find(id, adminId);
|
||||
return Lock(id, () =>
|
||||
{
|
||||
EnsurePlan(Current(id));
|
||||
return State(Current(id), resumed: false);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Abandon : dépôt supprimé, rien n'est publié.</summary>
|
||||
public PublicationRecord Cancel(string id, Guid adminId)
|
||||
{
|
||||
Find(id, adminId);
|
||||
return Lock(id, () =>
|
||||
{
|
||||
var record = Current(id);
|
||||
Delete(record);
|
||||
return record;
|
||||
});
|
||||
}
|
||||
|
||||
// ================================================================== Envoi
|
||||
|
||||
public ChunkResult AppendManifest(string id, Guid adminId, long offset, byte[] data)
|
||||
{
|
||||
Find(id, adminId);
|
||||
return Lock(id, () =>
|
||||
{
|
||||
var record = Current(id);
|
||||
var staging = Staging(record);
|
||||
var result = Append(Path.Combine(staging, "manifest.part"), Path.Combine(staging, "manifest.json"), offset, data,
|
||||
record.ManifestSize, record.ManifestSha256, "manifest");
|
||||
Touch(id);
|
||||
if (result.Complete) EnsurePlan(record);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
public ChunkResult AppendArchive(string id, Guid adminId, long offset, byte[] data)
|
||||
{
|
||||
Find(id, adminId);
|
||||
return Lock(id, () =>
|
||||
{
|
||||
var record = Current(id);
|
||||
var plan = RequirePlan(record);
|
||||
if (!plan.ArchiveNeeded) return new ChunkResult { Received = plan.ArchiveSize, Complete = true };
|
||||
var staging = Staging(record);
|
||||
var result = Append(Path.Combine(staging, "archive.part"), Path.Combine(staging, "archive.zip"), offset, data,
|
||||
plan.ArchiveSize, plan.ArchiveSha256, "archive");
|
||||
Touch(id);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Gros objet, par morceaux.</summary>
|
||||
public ChunkResult AppendObject(string id, Guid adminId, string sha256, long offset, byte[] data)
|
||||
{
|
||||
Find(id, adminId);
|
||||
var sha = sha256.ToLowerInvariant();
|
||||
return Lock(id, () =>
|
||||
{
|
||||
var record = Current(id);
|
||||
var plan = RequirePlan(record);
|
||||
if (!plan.Pending.TryGetValue(sha, out var size))
|
||||
throw ApiRejection.BadRequest("Cet objet n'est pas attendu par cette publication.");
|
||||
var objects = StagingObjects(record);
|
||||
var result = Append(Path.Combine(objects, sha + ".part"), Path.Combine(objects, sha + ".bin"), offset, data, size, sha, "objet");
|
||||
Touch(id);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Lot de petits objets (chacun vérifié) ; renvoie le nombre d'objets enregistrés.</summary>
|
||||
public ChunkResult AddObjects(string id, Guid adminId, byte[] body)
|
||||
{
|
||||
Find(id, adminId);
|
||||
List<(string Sha256, ReadOnlyMemory<byte> Data)> batch;
|
||||
try
|
||||
{
|
||||
batch = ObjectBatch.Read(body, PublishingLimits.MaxObjectsPerBatch);
|
||||
}
|
||||
catch (InvalidDataException ex)
|
||||
{
|
||||
throw ApiRejection.BadRequest(ex.Message);
|
||||
}
|
||||
|
||||
return Lock(id, () =>
|
||||
{
|
||||
var record = Current(id);
|
||||
var plan = RequirePlan(record);
|
||||
var objects = StagingObjects(record);
|
||||
var saved = 0;
|
||||
foreach (var (sha, data) in batch)
|
||||
{
|
||||
if (!plan.Pending.TryGetValue(sha, out var size))
|
||||
throw ApiRejection.BadRequest("Un objet du lot n'est pas attendu par cette publication.");
|
||||
if (data.Length != size) throw ApiRejection.BadRequest($"Objet {sha[..12]}… : taille différente de celle du manifest.");
|
||||
if (PublishedFiles.Sha256(data.Span) != sha)
|
||||
throw ApiRejection.BadRequest($"Objet {sha[..12]}… : contenu différent de son empreinte (fichier modifié ou abîmé).");
|
||||
var done = Path.Combine(objects, sha + ".bin");
|
||||
if (!File.Exists(done))
|
||||
{
|
||||
var temp = Path.Combine(objects, $"{sha}.{Guid.NewGuid():N}.tmp");
|
||||
PublishedFiles.WriteNew(temp, data.Span, PrivateFile);
|
||||
File.Move(temp, done, overwrite: true);
|
||||
}
|
||||
saved++;
|
||||
}
|
||||
Touch(id);
|
||||
return new ChunkResult { Received = saved, Complete = true };
|
||||
});
|
||||
}
|
||||
|
||||
// ================================================================== Mise en ligne
|
||||
|
||||
/// <summary>
|
||||
/// Met la version en ligne : objets, archive, puis manifest (historique des notes du serveur conservé).
|
||||
/// Déjà faite (réponse précédente perdue) : le même résultat est renvoyé.
|
||||
/// </summary>
|
||||
/// <returns>Le résultat, et l'enregistrement (null si la mise en ligne avait déjà eu lieu).</returns>
|
||||
/// <param name="signature">Signature du contenu du manifest par l'administrateur (phase 7), ou null.</param>
|
||||
/// <param name="verifySignature">Contrôle de la signature sur le condensé du manifest reçu (lève une ApiRejection).</param>
|
||||
public (PublicationResult Result, PublicationRecord? Record) Commit(string id, Guid adminId, ContentSignature? signature = null,
|
||||
Action<string, string>? verifySignature = null)
|
||||
{
|
||||
if (!PublishingLimits.IsValidPublicationId(id)) throw ApiRejection.NotFound("Publication introuvable.");
|
||||
if (Load(id) is null && LoadDone(id) is { } done && done.AdminId == adminId) return (done.Result, null);
|
||||
Find(id, adminId);
|
||||
return Lock(id, () =>
|
||||
{
|
||||
if (Load(id) is null && LoadDone(id) is { } finished && finished.AdminId == adminId) return (finished.Result, (PublicationRecord?)null);
|
||||
var record = Current(id);
|
||||
var (manifestPath, gameId) = Locate(record.GameId);
|
||||
var gameDirectory = Path.GetDirectoryName(manifestPath)!;
|
||||
var plan = RequirePlan(record);
|
||||
var state = State(record, resumed: false);
|
||||
if (state.PendingObjects.Count > 0 || !state.ArchiveComplete)
|
||||
throw ApiRejection.Conflict(
|
||||
$"Envoi incomplet : {state.PendingObjects.Count} fichier(s) et {(state.ArchiveComplete ? "aucune archive" : "l'archive")} restent à envoyer.");
|
||||
|
||||
var staging = Staging(record);
|
||||
var manifestBytes = File.ReadAllBytes(Path.Combine(staging, "manifest.json"));
|
||||
var errors = new List<string>();
|
||||
var summary = ManifestCheck.Check(manifestBytes, gameId, record.Version, errors)
|
||||
?? throw ApiRejection.BadRequest(string.Join(" ", errors.Take(5)));
|
||||
|
||||
using (PublishedFiles.LockGame(_dataDirectory, gameId))
|
||||
{
|
||||
var current = PublishedFiles.ReadManifest(manifestPath);
|
||||
var currentRoot = current is null ? null : ChangelogStore.Parse(current, gameId);
|
||||
var currentVersion = currentRoot is null ? "" : PublishedFiles.StringValue(currentRoot, "version") ?? "";
|
||||
// Fichiers en ligne changés pendant l'envoi (autre publication, WinSCP) : jamais écrasés. Une
|
||||
// correction des notes (page Notes de version) ne change que « changelog » : elle est permise.
|
||||
if (OnlineContentHash(currentRoot) != record.PublishedContentSha256)
|
||||
throw ApiRejection.Conflict(
|
||||
$"La version en ligne a changé pendant l'envoi ({Display(record.PublishedVersion)} → {Display(currentVersion)}) : " +
|
||||
"vérifiez avant de publier la vôtre, puis recommencez.", ApiErrorCodes.RevisionConflict);
|
||||
|
||||
// Signature (phase 7) : sur le contenu reçu (fichiers, archive, exécutable…), que la fusion des notes ne touche pas.
|
||||
if (signature is not null)
|
||||
verifySignature?.Invoke(gameId, ContentSigning.ManifestDigest(PublishedFiles.StripBom(manifestBytes)));
|
||||
|
||||
// Nouveau manifest : celui reçu, avec l'historique des notes du serveur (corrections en ligne) et la
|
||||
// signature de cette mise en ligne (une signature éventuellement présente dans le fichier reçu est retirée).
|
||||
var root = PublishedFiles.ParseObject(PublishedFiles.StripBom(manifestBytes), "Manifest");
|
||||
PublishedFiles.SetProperty(root, "changelog", MergeChangelog(root, currentRoot, record.Version));
|
||||
PublishedFiles.SetProperty(root, "signature",
|
||||
signature is null ? null : JsonSerializer.SerializeToNode(signature, Contracts.ApiJson.Options));
|
||||
var newBytes = PublishedFiles.Serialize(root);
|
||||
|
||||
// 0. Contrôles AVANT tout déplacement : droits, liens symboliques, dossiers objects/xx.
|
||||
if (new FileInfo(manifestPath).LinkTarget is not null)
|
||||
throw new ManifestWriteRefusedException("le manifest publié est un lien symbolique : le remplacer romprait le lien.");
|
||||
var archiveTarget = Path.Combine(gameDirectory, summary.ArchiveName);
|
||||
if (new FileInfo(archiveTarget).LinkTarget is not null)
|
||||
throw new ManifestWriteRefusedException("l'archive publiée est un lien symbolique : la remplacer romprait le lien.");
|
||||
var mode = PublishedFiles.PublicMode(gameDirectory, current is null ? null : manifestPath);
|
||||
var objectsDirectory = Path.Combine(gameDirectory, "objects");
|
||||
PublishedFiles.EnsurePublicDirectory(objectsDirectory, gameDirectory, keepSetGid: true);
|
||||
foreach (var prefix in plan.Pending.Keys.Select(sha => sha[..2]).Distinct())
|
||||
PublishedFiles.EnsurePublicDirectory(Path.Combine(objectsDirectory, prefix), gameDirectory, keepSetGid: false);
|
||||
|
||||
// 1. Objets (contenu adressé par l'empreinte : un objet présent est le bon s'il a la bonne taille).
|
||||
var added = 0;
|
||||
foreach (var (sha, size) in plan.Pending)
|
||||
{
|
||||
var staged = Path.Combine(StagingObjects(record), sha + ".bin");
|
||||
var target = Path.Combine(gameDirectory, ManifestCheck.ObjectPath(sha));
|
||||
if (ObjectPresent(target, size))
|
||||
{
|
||||
PublishedFiles.TryDelete(staged);
|
||||
continue;
|
||||
}
|
||||
if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(staged, mode);
|
||||
File.Move(staged, target, overwrite: true);
|
||||
added++;
|
||||
}
|
||||
foreach (var (sha, size) in summary.Objects)
|
||||
if (!ObjectPresent(Path.Combine(gameDirectory, ManifestCheck.ObjectPath(sha)), size))
|
||||
throw ApiRejection.Conflict(
|
||||
$"Le fichier {ManifestCheck.ObjectPath(sha)} a disparu du serveur pendant l'envoi : abandonnez puis recommencez la publication.");
|
||||
|
||||
// 2. Archive : l'ancienne (même nom : republication) est gardée de côté jusqu'à l'écriture du manifest.
|
||||
var stagedArchive = Path.Combine(staging, "archive.zip");
|
||||
string? previousArchive = null;
|
||||
var archiveAdded = false;
|
||||
if (File.Exists(stagedArchive))
|
||||
{
|
||||
if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(stagedArchive, mode);
|
||||
if (File.Exists(archiveTarget))
|
||||
{
|
||||
previousArchive = PublishedFiles.TempPath(archiveTarget);
|
||||
File.Move(archiveTarget, previousArchive);
|
||||
}
|
||||
File.Move(stagedArchive, archiveTarget);
|
||||
archiveAdded = true;
|
||||
}
|
||||
else if (!File.Exists(archiveTarget) || new FileInfo(archiveTarget).Length != summary.ArchiveSize)
|
||||
{
|
||||
throw ApiRejection.Conflict("L'archive a disparu du serveur pendant l'envoi : abandonnez puis recommencez la publication.");
|
||||
}
|
||||
|
||||
// 3. Manifest en dernier : les launchers ne voient jamais une version incomplète.
|
||||
try
|
||||
{
|
||||
if (current is not null) PublishedFiles.Backup(_dataDirectory, gameId, current);
|
||||
PublishedFiles.WriteManifest(manifestPath, newBytes, current is null ? null : PublishedFiles.Sha256(current),
|
||||
() => ApiRejection.Conflict("Le manifest a été modifié pendant la mise en ligne : recommencez.", ApiErrorCodes.RevisionConflict));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Archive remise dans le dépôt (pas besoin de la renvoyer), l'ancienne remise en ligne.
|
||||
if (archiveAdded)
|
||||
{
|
||||
File.Move(archiveTarget, stagedArchive, overwrite: true);
|
||||
if (previousArchive is not null) File.Move(previousArchive, archiveTarget);
|
||||
}
|
||||
throw;
|
||||
}
|
||||
if (previousArchive is not null) PublishedFiles.TryDelete(previousArchive);
|
||||
|
||||
var result = new PublicationResult
|
||||
{
|
||||
GameId = gameId,
|
||||
Version = record.Version,
|
||||
PreviousVersion = currentVersion,
|
||||
ManifestSha256 = PublishedFiles.Sha256(newBytes),
|
||||
ObjectsAdded = added,
|
||||
ArchiveAdded = archiveAdded
|
||||
};
|
||||
SaveDone(new PublicationDone { Id = id, AdminId = record.AdminId, Result = result, At = _time.GetUtcNow() });
|
||||
Delete(record);
|
||||
return (result, (PublicationRecord?)record);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notes : celles de la version publiée (du manifest reçu ; à défaut, celles déjà en ligne), puis
|
||||
/// l'historique du SERVEUR (qui contient les corrections faites en ligne), dans les bornes du launcher
|
||||
/// (30 versions ; les plus anciennes retirées si l'ensemble est trop long).
|
||||
/// Sans manifest en ligne : les notes du manifest reçu.
|
||||
/// </summary>
|
||||
private static JsonArray? MergeChangelog(JsonObject uploaded, JsonObject? current, string version)
|
||||
{
|
||||
var received = ChangelogStore.ChangelogArray(uploaded);
|
||||
if (current is null) return received?.DeepClone().AsArray();
|
||||
|
||||
static bool Is(JsonNode? node, string version) =>
|
||||
node is JsonObject entry && PublishedFiles.StringValue(entry, "version") == version;
|
||||
|
||||
var history = ChangelogStore.ChangelogArray(current) ?? new JsonArray();
|
||||
var merged = new JsonArray();
|
||||
var head = received?.FirstOrDefault(n => Is(n, version)) ?? history.FirstOrDefault(n => Is(n, version));
|
||||
if (head is not null) merged.Add(head.DeepClone());
|
||||
foreach (var entry in history)
|
||||
{
|
||||
if (merged.Count >= ChangelogLimits.MaxEntries) break;
|
||||
if (entry is JsonObject && !Is(entry, version)) merged.Add(entry.DeepClone());
|
||||
}
|
||||
// Budget de tout l'historique (contrôle de lecture en même temps : une entrée illisible empêche la publication).
|
||||
while (merged.Count > 1 &&
|
||||
ChangelogStore.Entries(merged).OfType<ChangelogEntry>().Sum(ChangelogLimits.DisplayedItems) > ChangelogLimits.MaxTotalItems)
|
||||
merged.RemoveAt(merged.Count - 1);
|
||||
return merged;
|
||||
}
|
||||
|
||||
/// <summary>Empreinte de la version en ligne hors notes et hors signature (une correction de notes ou une signature reste permise pendant l'envoi) ; vide si elle n'existe pas.</summary>
|
||||
private static string OnlineContentHash(JsonObject? root)
|
||||
{
|
||||
if (root is null) return "";
|
||||
var copy = root.DeepClone().AsObject();
|
||||
PublishedFiles.SetProperty(copy, "changelog", null);
|
||||
PublishedFiles.SetProperty(copy, "signature", null);
|
||||
return PublishedFiles.Sha256(System.Text.Encoding.UTF8.GetBytes(copy.ToJsonString()));
|
||||
}
|
||||
|
||||
// ================================================================== Plan (après réception du manifest)
|
||||
|
||||
/// <summary>Manifest complet : contrôle, puis liste des objets et de l'archive à envoyer.</summary>
|
||||
private void EnsurePlan(PublicationRecord record)
|
||||
{
|
||||
if (record.Plan is not null) return;
|
||||
var staging = Staging(record);
|
||||
var manifest = Path.Combine(staging, "manifest.json");
|
||||
if (!File.Exists(manifest)) return;
|
||||
|
||||
var (manifestPath, gameId) = Locate(record.GameId);
|
||||
var gameDirectory = Path.GetDirectoryName(manifestPath)!;
|
||||
var errors = new List<string>();
|
||||
var summary = ManifestCheck.Check(File.ReadAllBytes(manifest), gameId, record.Version, errors);
|
||||
if (summary is null)
|
||||
{
|
||||
Delete(record); // manifest refusé : la publication est abandonnée (à refaire avec un manifest corrigé)
|
||||
throw ApiRejection.BadRequest("Manifest refusé : " + string.Join(" ", errors.Take(5)) +
|
||||
(errors.Count > 5 ? $" (+{errors.Count - 5} autres)" : ""));
|
||||
}
|
||||
|
||||
var pending = summary.Objects
|
||||
.Where(o => !ObjectPresent(Path.Combine(gameDirectory, ManifestCheck.ObjectPath(o.Key)), o.Value))
|
||||
.ToDictionary(o => o.Key, o => o.Value, StringComparer.Ordinal);
|
||||
// Archive déjà en ligne (même nom, même taille, même empreinte DANS le manifest en ligne) : pas renvoyée.
|
||||
// Aucune lecture du fichier (plusieurs Go) pendant la requête.
|
||||
var archiveTarget = new FileInfo(Path.Combine(gameDirectory, summary.ArchiveName));
|
||||
var archiveNeeded = !(archiveTarget.Exists && archiveTarget.LinkTarget is null && archiveTarget.Length == summary.ArchiveSize &&
|
||||
PublishedArchive(manifestPath, gameId) is { } online &&
|
||||
string.Equals(online.Name, summary.ArchiveName, StringComparison.Ordinal) && online.Sha256 == summary.ArchiveSha256);
|
||||
|
||||
var needed = pending.Values.Sum() + (archiveNeeded ? summary.ArchiveSize : 0);
|
||||
var free = new DriveInfo(gameDirectory).AvailableFreeSpace;
|
||||
if (free < needed + FreeSpaceMargin)
|
||||
throw new ApiRejection(507, ApiErrorCodes.InsufficientStorage,
|
||||
$"Espace disque insuffisant sur le serveur : {needed / (1024 * 1024)} Mo à recevoir, {free / (1024 * 1024)} Mo libres.");
|
||||
|
||||
Save(record with
|
||||
{
|
||||
Plan = new PublicationPlan
|
||||
{
|
||||
ArchiveName = summary.ArchiveName,
|
||||
ArchiveSize = summary.ArchiveSize,
|
||||
ArchiveSha256 = summary.ArchiveSha256,
|
||||
ArchiveNeeded = archiveNeeded,
|
||||
Pending = pending,
|
||||
ObjectCount = summary.Objects.Count,
|
||||
ObjectsOnServer = summary.Objects.Count - pending.Count
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Archive référencée par le manifest en ligne (nom, empreinte), sans la lire.</summary>
|
||||
private static (string Name, string Sha256)? PublishedArchive(string manifestPath, string gameId)
|
||||
{
|
||||
var bytes = PublishedFiles.ReadManifest(manifestPath);
|
||||
if (bytes is null) return null;
|
||||
var root = ChangelogStore.Parse(bytes, gameId);
|
||||
if (PublishedFiles.Property(root, "archive") is not JsonObject archive) return null;
|
||||
var name = PublishedFiles.StringValue(archive, "url");
|
||||
var sha = PublishedFiles.StringValue(archive, "sha256");
|
||||
return name is null || sha is null ? null : (name, sha.ToLowerInvariant());
|
||||
}
|
||||
|
||||
private PublicationPlan RequirePlan(PublicationRecord record)
|
||||
{
|
||||
EnsurePlan(record);
|
||||
return Current(record.Id).Plan ?? throw ApiRejection.Conflict("Envoyez d'abord le manifest.");
|
||||
}
|
||||
|
||||
private PublicationState State(PublicationRecord record, bool resumed)
|
||||
{
|
||||
var staging = Staging(record);
|
||||
var manifestDone = File.Exists(Path.Combine(staging, "manifest.json"));
|
||||
var plan = record.Plan;
|
||||
var pending = new List<string>();
|
||||
var partial = new Dictionary<string, long>(StringComparer.Ordinal);
|
||||
long archiveReceived = 0;
|
||||
var archiveComplete = false;
|
||||
if (plan is not null)
|
||||
{
|
||||
var (manifestPath, _) = Locate(record.GameId);
|
||||
var gameDirectory = Path.GetDirectoryName(manifestPath)!;
|
||||
var objects = Path.Combine(staging, "objects");
|
||||
foreach (var (sha, size) in plan.Pending)
|
||||
{
|
||||
if (File.Exists(Path.Combine(objects, sha + ".bin")) ||
|
||||
ObjectPresent(Path.Combine(gameDirectory, ManifestCheck.ObjectPath(sha)), size))
|
||||
continue;
|
||||
pending.Add(sha);
|
||||
var part = new FileInfo(Path.Combine(objects, sha + ".part"));
|
||||
if (part.Exists && part.Length > 0) partial[sha] = part.Length;
|
||||
}
|
||||
archiveComplete = !plan.ArchiveNeeded || File.Exists(Path.Combine(staging, "archive.zip"));
|
||||
archiveReceived = archiveComplete ? plan.ArchiveSize : Length(Path.Combine(staging, "archive.part"));
|
||||
}
|
||||
|
||||
return new PublicationState
|
||||
{
|
||||
PublicationId = record.Id,
|
||||
GameId = record.GameId,
|
||||
Version = record.Version,
|
||||
PublishedVersion = record.PublishedVersion,
|
||||
Resumed = resumed,
|
||||
ManifestSize = record.ManifestSize,
|
||||
ManifestReceived = manifestDone ? record.ManifestSize : Length(Path.Combine(staging, "manifest.part")),
|
||||
ManifestAccepted = plan is not null,
|
||||
ObjectCount = plan?.ObjectCount ?? 0,
|
||||
ObjectsOnServer = plan?.ObjectsOnServer ?? 0,
|
||||
PendingObjects = pending,
|
||||
PartialObjects = partial,
|
||||
ArchiveName = plan?.ArchiveName,
|
||||
ArchiveSize = plan?.ArchiveSize ?? 0,
|
||||
ArchiveReceived = archiveReceived,
|
||||
ArchiveComplete = archiveComplete,
|
||||
ExpiresAt = LastActivity(record.Id) + PublishingLimits.IdleExpiry
|
||||
};
|
||||
}
|
||||
|
||||
// ================================================================== Réception d'un fichier
|
||||
|
||||
/// <summary>
|
||||
/// Ajoute un morceau à la suite de ce qui a été reçu (position vérifiée), puis vérifie l'empreinte
|
||||
/// quand le fichier est complet (calculée au fil des morceaux ; recalculée après un redémarrage).
|
||||
/// </summary>
|
||||
private ChunkResult Append(string partPath, string donePath, long offset, byte[] data, long size, string sha256, string what)
|
||||
{
|
||||
if (File.Exists(donePath)) return new ChunkResult { Received = size, Complete = true };
|
||||
var received = Length(partPath);
|
||||
if (offset != received)
|
||||
throw ApiRejection.Conflict($"Position d'envoi {offset} inattendue ({what} : {received} octets déjà reçus).", ApiErrorCodes.OffsetMismatch);
|
||||
if (received + data.LongLength > size) throw ApiRejection.BadRequest($"Données en trop ({what} : {size} octets attendus).");
|
||||
if (data.Length == 0 && size > 0) throw ApiRejection.BadRequest("Morceau vide.");
|
||||
|
||||
var options = new FileStreamOptions { Mode = FileMode.Append, Access = FileAccess.Write, Share = FileShare.None };
|
||||
if (!OperatingSystem.IsWindows()) options.UnixCreateMode = PrivateFile;
|
||||
using (var stream = new FileStream(partPath, options))
|
||||
{
|
||||
stream.Write(data);
|
||||
stream.Flush(flushToDisk: true);
|
||||
}
|
||||
|
||||
HashProgress? progress;
|
||||
if (received == 0)
|
||||
{
|
||||
RemoveHash(partPath);
|
||||
progress = _hashes[partPath] = new HashProgress();
|
||||
}
|
||||
else if (!_hashes.TryGetValue(partPath, out progress) || progress.Offset != received)
|
||||
{
|
||||
RemoveHash(partPath);
|
||||
progress = null;
|
||||
}
|
||||
progress?.Add(data);
|
||||
received += data.Length;
|
||||
if (received < size) return new ChunkResult { Received = received };
|
||||
|
||||
var actual = progress is not null && progress.Offset == size ? progress.Finish() : HashFile(partPath);
|
||||
RemoveHash(partPath);
|
||||
if (actual != sha256)
|
||||
{
|
||||
PublishedFiles.TryDelete(partPath);
|
||||
throw ApiRejection.BadRequest($"Empreinte différente pour {what} : fichier abîmé ou modifié pendant l'envoi ; il sera renvoyé.");
|
||||
}
|
||||
File.Move(partPath, donePath, overwrite: true);
|
||||
return new ChunkResult { Received = size, Complete = true };
|
||||
}
|
||||
|
||||
private void RemoveHash(string key)
|
||||
{
|
||||
if (_hashes.TryRemove(key, out var progress)) progress.Dispose();
|
||||
}
|
||||
|
||||
private sealed class HashProgress : IDisposable
|
||||
{
|
||||
private readonly IncrementalHash _hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
|
||||
public long Offset { get; private set; }
|
||||
|
||||
public void Add(byte[] data)
|
||||
{
|
||||
_hash.AppendData(data);
|
||||
Offset += data.Length;
|
||||
}
|
||||
|
||||
public string Finish() => Convert.ToHexString(_hash.GetHashAndReset()).ToLowerInvariant();
|
||||
|
||||
public void Dispose() => _hash.Dispose();
|
||||
}
|
||||
|
||||
// ================================================================== Enregistrements, dépôt
|
||||
|
||||
public sealed record PublicationRecord
|
||||
{
|
||||
public string Id { get; init; } = "";
|
||||
public string GameId { get; init; } = "";
|
||||
public string Version { get; init; } = "";
|
||||
public string ManifestSha256 { get; init; } = "";
|
||||
public long ManifestSize { get; init; }
|
||||
public Guid AdminId { get; init; }
|
||||
public string AdminName { get; init; } = "";
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>Version en ligne au début (vide : aucune).</summary>
|
||||
public string PublishedVersion { get; init; } = "";
|
||||
|
||||
/// <summary>Empreinte du manifest en ligne SANS les notes, au début : la mise en ligne est refusée si elle a changé.</summary>
|
||||
public string PublishedContentSha256 { get; init; } = "";
|
||||
|
||||
public PublicationPlan? Plan { get; init; }
|
||||
}
|
||||
|
||||
public sealed record PublicationPlan
|
||||
{
|
||||
public string ArchiveName { get; init; } = "";
|
||||
public long ArchiveSize { get; init; }
|
||||
public string ArchiveSha256 { get; init; } = "";
|
||||
public bool ArchiveNeeded { get; init; }
|
||||
|
||||
/// <summary>Objets absents du serveur quand le manifest a été reçu (empreinte → taille).</summary>
|
||||
public Dictionary<string, long> Pending { get; init; } = new(StringComparer.Ordinal);
|
||||
|
||||
public int ObjectCount { get; init; }
|
||||
public int ObjectsOnServer { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Publication terminée : résultat gardé 24 h (réponse perdue, nouvel essai).</summary>
|
||||
public sealed record PublicationDone
|
||||
{
|
||||
public string Id { get; init; } = "";
|
||||
public Guid AdminId { get; init; }
|
||||
public PublicationResult Result { get; init; } = new();
|
||||
public DateTimeOffset At { get; init; }
|
||||
}
|
||||
|
||||
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 PublishedFiles.NotConfigured(gameId, "Publication");
|
||||
}
|
||||
|
||||
/// <summary>Publication de cet administrateur, encore valable (sinon 404/403).</summary>
|
||||
private PublicationRecord Find(string id, Guid adminId)
|
||||
{
|
||||
if (!PublishingLimits.IsValidPublicationId(id)) throw ApiRejection.NotFound("Publication introuvable.");
|
||||
CleanUp(force: false);
|
||||
var record = Load(id) ?? throw ApiRejection.NotFound("Publication introuvable (terminée, abandonnée ou expirée).");
|
||||
if (record.AdminId != adminId)
|
||||
throw new ApiRejection(403, ApiErrorCodes.Forbidden, $"Cette publication a été lancée par {record.AdminName}.");
|
||||
return record;
|
||||
}
|
||||
|
||||
/// <summary>Relue sous le verrou : abandonnée entre-temps → 404.</summary>
|
||||
private PublicationRecord Current(string id) =>
|
||||
Load(id) ?? throw ApiRejection.NotFound("Publication introuvable (terminée, abandonnée ou expirée).");
|
||||
|
||||
private string RecordPath(string id) => Path.Combine(_recordsDirectory, id + ".json");
|
||||
|
||||
private string DonePath(string id) => Path.Combine(_recordsDirectory, id + DoneSuffix + ".json");
|
||||
|
||||
private PublicationRecord? Load(string id) => Read<PublicationRecord>(RecordPath(id));
|
||||
|
||||
private PublicationDone? LoadDone(string id) => Read<PublicationDone>(DonePath(id));
|
||||
|
||||
private static T? Read<T>(string path) where T : class
|
||||
{
|
||||
if (!File.Exists(path)) return null;
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<T>(File.ReadAllText(path), ApiJson.Options);
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or IOException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<PublicationRecord> Records()
|
||||
{
|
||||
if (!Directory.Exists(_recordsDirectory)) yield break;
|
||||
foreach (var file in Directory.GetFiles(_recordsDirectory, "*.json"))
|
||||
{
|
||||
var id = Path.GetFileNameWithoutExtension(file);
|
||||
if (PublishingLimits.IsValidPublicationId(id) && Load(id) is { } record) yield return record;
|
||||
}
|
||||
}
|
||||
|
||||
private void Save(PublicationRecord record)
|
||||
{
|
||||
SecureFiles.EnsureDirectory(_recordsDirectory);
|
||||
SecureFiles.WriteAllTextAtomic(RecordPath(record.Id), JsonSerializer.Serialize(record, ApiJson.Options), keepBackup: false);
|
||||
}
|
||||
|
||||
private void SaveDone(PublicationDone done)
|
||||
{
|
||||
SecureFiles.EnsureDirectory(_recordsDirectory);
|
||||
SecureFiles.WriteAllTextAtomic(DonePath(done.Id), JsonSerializer.Serialize(done, ApiJson.Options), keepBackup: false);
|
||||
}
|
||||
|
||||
/// <summary>Dernier envoi : date de modification de l'enregistrement (mise à jour à chaque morceau).</summary>
|
||||
private DateTimeOffset LastActivity(string id)
|
||||
{
|
||||
var path = RecordPath(id);
|
||||
return File.Exists(path) ? new DateTimeOffset(File.GetLastWriteTimeUtc(path), TimeSpan.Zero) : DateTimeOffset.MinValue;
|
||||
}
|
||||
|
||||
private void Touch(string id)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.SetLastWriteTimeUtc(RecordPath(id), _time.GetUtcNow().UtcDateTime);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// simple indication d'activité
|
||||
}
|
||||
}
|
||||
|
||||
private string Staging(PublicationRecord record) =>
|
||||
Path.Combine(Path.GetDirectoryName(Locate(record.GameId).Path)!, StagingPrefix + record.Id);
|
||||
|
||||
private string StagingObjects(PublicationRecord record)
|
||||
{
|
||||
var objects = Path.Combine(Staging(record), "objects");
|
||||
if (!Directory.Exists(objects)) CreatePrivateDirectory(objects);
|
||||
return objects;
|
||||
}
|
||||
|
||||
private void CreateStaging(PublicationRecord record)
|
||||
{
|
||||
var staging = Staging(record);
|
||||
if (new DirectoryInfo(Path.GetDirectoryName(staging)!).LinkTarget is not null)
|
||||
throw new ManifestWriteRefusedException("le dossier du jeu est un lien symbolique.");
|
||||
CreatePrivateDirectory(staging);
|
||||
}
|
||||
|
||||
private static void CreatePrivateDirectory(string path)
|
||||
{
|
||||
if (OperatingSystem.IsWindows()) Directory.CreateDirectory(path);
|
||||
else Directory.CreateDirectory(path, PrivateDirectory);
|
||||
}
|
||||
|
||||
private void Delete(PublicationRecord record)
|
||||
{
|
||||
foreach (var key in _hashes.Keys.Where(k => k.Contains(StagingPrefix + record.Id, StringComparison.Ordinal)).ToList())
|
||||
RemoveHash(key);
|
||||
try
|
||||
{
|
||||
var staging = Staging(record);
|
||||
if (Directory.Exists(staging)) Directory.Delete(staging, recursive: true);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ApiRejection)
|
||||
{
|
||||
// dépôt laissé : supprimé au prochain nettoyage
|
||||
}
|
||||
PublishedFiles.TryDelete(RecordPath(record.Id));
|
||||
_locks.TryRemove(record.Id, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publications sans envoi depuis 48 h, résultats de plus de 24 h et dépôts orphelins : supprimés
|
||||
/// (au début d'une publication, et au plus toutes les 10 minutes lors des envois).
|
||||
/// </summary>
|
||||
private void CleanUp(bool force)
|
||||
{
|
||||
var now = _time.GetUtcNow();
|
||||
lock (_locks)
|
||||
{
|
||||
if (!force && now - _lastCleanUp < CleanUpEvery) return;
|
||||
_lastCleanUp = now;
|
||||
}
|
||||
var known = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var record in Records().ToList())
|
||||
{
|
||||
if (now - LastActivity(record.Id) > PublishingLimits.IdleExpiry)
|
||||
Lock(record.Id, () =>
|
||||
{
|
||||
Delete(record);
|
||||
return 0;
|
||||
});
|
||||
else known.Add(record.Id);
|
||||
}
|
||||
if (Directory.Exists(_recordsDirectory))
|
||||
foreach (var done in Directory.GetFiles(_recordsDirectory, "*" + DoneSuffix + ".json"))
|
||||
if (now - new DateTimeOffset(File.GetLastWriteTimeUtc(done), TimeSpan.Zero) > DoneLifetime)
|
||||
PublishedFiles.TryDelete(done);
|
||||
foreach (var manifest in _manifests.Values)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(manifest)!;
|
||||
if (!Directory.Exists(directory)) continue;
|
||||
foreach (var staging in Directory.GetDirectories(directory, StagingPrefix + "*"))
|
||||
{
|
||||
var id = Path.GetFileName(staging)[StagingPrefix.Length..];
|
||||
if (known.Contains(id) || now - Directory.GetLastWriteTimeUtc(staging) <= PublishingLimits.IdleExpiry) continue;
|
||||
try
|
||||
{
|
||||
Directory.Delete(staging, recursive: true);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// nouvel essai au prochain nettoyage
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private T Lock<T>(string id, Func<T> action)
|
||||
{
|
||||
lock (_locks.GetOrAdd(id, _ => new object())) return action();
|
||||
}
|
||||
|
||||
private static bool ObjectPresent(string path, long size)
|
||||
{
|
||||
var info = new FileInfo(path);
|
||||
return info.Exists && info.LinkTarget is null && info.Length == size;
|
||||
}
|
||||
|
||||
private static long Length(string path)
|
||||
{
|
||||
var info = new FileInfo(path);
|
||||
return info.Exists ? info.Length : 0;
|
||||
}
|
||||
|
||||
private static string HashFile(string path)
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string Display(string version) => string.IsNullOrEmpty(version) ? "aucune" : version;
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
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.Dtos;
|
||||
using GameLauncher.Server.Hosting;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Fichiers PUBLICS d'un jeu (dossier servi par nginx : manifest.json, archive, objects/), communs aux
|
||||
/// notes de version (phase 4) et à la publication d'une version (phase 5) :
|
||||
/// • même verrou par jeu (une seule écriture du manifest à la fois, service et outils confondus) ;
|
||||
/// • même présentation JSON que ManifestBuilder ;
|
||||
/// • écriture atomique : fichier temporaire caché (refusé par nginx) dans le même dossier, puis renommage ;
|
||||
/// • droits : ceux du manifest existant (sinon 0640 avec le groupe du dossier setgid, ou 0644) ;
|
||||
/// refus si le dossier n'a pas le bit setgid alors que nginx lit les fichiers par leur groupe,
|
||||
/// ou si le manifest est un lien symbolique.
|
||||
/// </summary>
|
||||
internal static class PublishedFiles
|
||||
{
|
||||
/// <summary>Taille maximale d'un manifest lu ou écrit (même borne que le launcher).</summary>
|
||||
public const long MaxManifestBytes = 64L * 1024 * 1024;
|
||||
|
||||
public static readonly TimeSpan LockTimeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
private const string HistoryFolder = "manifest-history";
|
||||
private const int HistoryLength = 20;
|
||||
|
||||
private const UnixFileMode GroupReadable = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead;
|
||||
private const UnixFileMode AllReadable = GroupReadable | UnixFileMode.OtherRead;
|
||||
|
||||
/// <summary>Même présentation que ManifestBuilder (indenté, camelCase, accents non échappés).</summary>
|
||||
public static readonly JsonSerializerOptions ManifestJson = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = true,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
MaxDepth = 64
|
||||
};
|
||||
|
||||
public static readonly JsonDocumentOptions ReadOptions = new()
|
||||
{
|
||||
CommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
MaxDepth = 64
|
||||
};
|
||||
|
||||
/// <summary>Verrou d'écriture du manifest d'un jeu (fichier dans les données privées).</summary>
|
||||
public static FileStream LockGame(string dataDirectory, string gameId) =>
|
||||
SecureFiles.AcquireLock(Path.Combine(dataDirectory, $"manifest-{gameId.ToLowerInvariant()}.lock"), LockTimeout);
|
||||
|
||||
public static string Sha256(ReadOnlySpan<byte> bytes) => Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
|
||||
|
||||
public static ReadOnlySpan<byte> StripBom(byte[] bytes) =>
|
||||
bytes.AsSpan().StartsWith(new byte[] { 0xEF, 0xBB, 0xBF }) ? bytes.AsSpan(3) : bytes;
|
||||
|
||||
/// <summary>Manifest publié (null s'il n'existe pas encore).</summary>
|
||||
public static byte[]? ReadManifest(string path)
|
||||
{
|
||||
var info = new FileInfo(path);
|
||||
if (!info.Exists) return null;
|
||||
if (info.Length > MaxManifestBytes) throw new InvalidDataException("Manifest trop volumineux.");
|
||||
return File.ReadAllBytes(path);
|
||||
}
|
||||
|
||||
public static JsonObject ParseObject(ReadOnlySpan<byte> bytes, string what)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonNode.Parse(bytes, documentOptions: ReadOptions) as JsonObject
|
||||
?? throw new InvalidDataException($"{what} : objet JSON attendu.");
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or ArgumentException)
|
||||
{
|
||||
throw new InvalidDataException($"{what} illisible : {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Propriété d'un objet JSON, nom insensible à la casse (comme pour le launcher).</summary>
|
||||
public static JsonNode? Property(JsonObject node, string name)
|
||||
{
|
||||
foreach (var (key, value) in node)
|
||||
if (string.Equals(key, name, StringComparison.OrdinalIgnoreCase))
|
||||
return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
public static string? StringValue(JsonObject node, string name) =>
|
||||
Property(node, name) is JsonValue v && v.TryGetValue<string>(out var text) ? text : null;
|
||||
|
||||
/// <summary>Remplace une propriété (toutes casses confondues) ; null la retire.</summary>
|
||||
public static void SetProperty(JsonObject node, string name, JsonNode? value)
|
||||
{
|
||||
foreach (var existing in node.Select(p => p.Key).Where(k => string.Equals(k, name, StringComparison.OrdinalIgnoreCase)).ToList())
|
||||
node.Remove(existing);
|
||||
if (value is not null) node[name] = value;
|
||||
}
|
||||
|
||||
public static byte[] Serialize(JsonObject root)
|
||||
{
|
||||
var bytes = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(root.ToJsonString(ManifestJson));
|
||||
if (bytes.LongLength > MaxManifestBytes) throw ApiRejection.BadRequest("Manifest trop volumineux.");
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/// <summary>Copie de l'ancien manifest dans les données privées (aide à la récupération ; 20 dernières).</summary>
|
||||
public static void Backup(string dataDirectory, 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 l'écriture.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Droits d'un nouveau fichier public du dossier : ceux du manifest existant, sinon lisible par le
|
||||
/// groupe du dossier (setgid) ou par tous. Refus si nginx risque de perdre l'accès.
|
||||
/// </summary>
|
||||
public static UnixFileMode PublicMode(string directory, string? referenceFile)
|
||||
{
|
||||
if (OperatingSystem.IsWindows()) return AllReadable;
|
||||
var directoryMode = File.GetUnixFileMode(directory);
|
||||
var setgid = directoryMode.HasFlag(UnixFileMode.SetGroup);
|
||||
var mode = referenceFile is not null && File.Exists(referenceFile)
|
||||
? File.GetUnixFileMode(referenceFile)
|
||||
: setgid ? GroupReadable : AllReadable;
|
||||
// Le fichier créé appartient au compte du service, avec le groupe du dossier SEULEMENT si celui-ci
|
||||
// a le bit setgid : sinon nginx (groupe www-data) ne pourrait plus lire un fichier lisible par le groupe.
|
||||
if (!mode.HasFlag(UnixFileMode.OtherRead) && mode.HasFlag(UnixFileMode.GroupRead) && !setgid)
|
||||
throw new ManifestWriteRefusedException(
|
||||
"le dossier du jeu n'a pas le bit setgid (chmod g+s) : les fichiers écrits ne seraient plus lisibles par nginx.");
|
||||
// Fichier existant : ses droits sont repris tels quels (rw des comptes compris) ; nouveau fichier : lecture.
|
||||
return mode & (UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
|
||||
UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute |
|
||||
UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sous-dossier public (objects, objects/ab) : droits du dossier du jeu, fixés explicitement (le masque
|
||||
/// du service, UMask=0077, retirerait l'accès de nginx). Refus s'il s'agit d'un lien symbolique.
|
||||
/// <paramref name="keepSetGid"/> : dossier qui en contiendra d'autres (objects) ; son bit setgid doit être
|
||||
/// conservé pour que les sous-dossiers créés ensuite aient le groupe lu par nginx. (Un dossier
|
||||
/// « feuille » garde de toute façon le groupe hérité à sa création, comme les fichiers qui y sont déplacés.)
|
||||
/// </summary>
|
||||
public static void EnsurePublicDirectory(string path, string gameDirectory, bool keepSetGid)
|
||||
{
|
||||
var info = new DirectoryInfo(path);
|
||||
if (info.LinkTarget is not null)
|
||||
throw new ManifestWriteRefusedException($"« {Path.GetFileName(path)} » est un lien symbolique dans le dossier du jeu.");
|
||||
if (info.Exists) return;
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
Directory.CreateDirectory(path);
|
||||
return;
|
||||
}
|
||||
var gameMode = File.GetUnixFileMode(gameDirectory);
|
||||
var permissions = gameMode & PermissionBits;
|
||||
var needSetGid = keepSetGid && gameMode.HasFlag(UnixFileMode.SetGroup);
|
||||
// Jamais de bit spécial demandé : systemd l'interdit au service (RestrictSUIDSGID=yes, refus EPERM).
|
||||
// Dans un dossier setgid, le noyau transmet de lui-même le bit setgid et le groupe (www-data).
|
||||
Directory.CreateDirectory(path, permissions);
|
||||
if ((File.GetUnixFileMode(path) & PermissionBits) != permissions) // réduits par le masque du service (UMask=0077)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Fixer les droits retire le setgid hérité : sans conséquence pour un dossier « feuille » (son
|
||||
// groupe reste celui hérité) ; « objects » doit le redemander, ce que systemd peut refuser.
|
||||
File.SetUnixFileMode(path, needSetGid ? permissions | UnixFileMode.SetGroup : permissions);
|
||||
}
|
||||
catch (UnauthorizedAccessException) when (needSetGid)
|
||||
{
|
||||
// traité ci-dessous
|
||||
}
|
||||
}
|
||||
// Sans setgid (ou si le compte du service n'appartient pas au groupe du dossier, Linux le retire), les
|
||||
// sous-dossiers créés ensuite n'auraient plus le groupe lu par nginx.
|
||||
if (needSetGid && File.GetUnixFileMode(path) != (permissions | UnixFileMode.SetGroup))
|
||||
{
|
||||
TryDeleteDirectory(path);
|
||||
throw new ManifestWriteRefusedException(
|
||||
"le dossier « objects » n'existe pas encore et le service ne peut pas le créer avec le bit setgid et l'accès de nginx : " +
|
||||
"créez-le sur le VPS (server/deploy/LISEZMOI.md, étape 3 quater).");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Droits rwx des trois classes, sans setuid, setgid ni sticky.</summary>
|
||||
private const UnixFileMode PermissionBits =
|
||||
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
|
||||
UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute |
|
||||
UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute;
|
||||
|
||||
private static void TryDeleteDirectory(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(path);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// laissé : vide, sans conséquence
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remplace (ou crée) le manifest publié. <paramref name="expectedSha256"/> : empreinte du manifest
|
||||
/// sur lequel l'écriture a été préparée (null : il ne devait pas exister) ; revérifiée juste avant le
|
||||
/// renommage, pour ne jamais écraser un envoi (WinSCP…) fait entre-temps.
|
||||
/// </summary>
|
||||
public static void WriteManifest(string path, byte[] bytes, string? expectedSha256, Func<ApiRejection> conflict)
|
||||
{
|
||||
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.");
|
||||
var mode = PublicMode(directory, path);
|
||||
var temp = TempPath(path);
|
||||
try
|
||||
{
|
||||
WriteNew(temp, bytes, mode);
|
||||
var current = ReadManifest(path);
|
||||
if (current is null ? expectedSha256 is not null : !string.Equals(Sha256(current), expectedSha256, StringComparison.OrdinalIgnoreCase))
|
||||
throw conflict();
|
||||
File.Move(temp, path, overwrite: true);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ApiRejection)
|
||||
{
|
||||
TryDelete(temp);
|
||||
if (ex is ApiRejection or ManifestWriteRefusedException) throw;
|
||||
throw new IOException($"Écriture du manifest impossible ({path}) : {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Fichier temporaire caché du même dossier (nginx refuse les noms commençant par un point).</summary>
|
||||
public static string TempPath(string path) =>
|
||||
Path.Combine(Path.GetDirectoryName(path)!, $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp");
|
||||
|
||||
public static void WriteNew(string path, ReadOnlySpan<byte> bytes, UnixFileMode mode)
|
||||
{
|
||||
using (var stream = new FileStream(path, new FileStreamOptions { Mode = FileMode.CreateNew, Access = FileAccess.Write, Share = FileShare.None }))
|
||||
{
|
||||
stream.Write(bytes);
|
||||
stream.Flush(flushToDisk: true);
|
||||
}
|
||||
if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(path, mode);
|
||||
}
|
||||
|
||||
public static void TryDelete(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// laissé en place : sans conséquence (nom unique, caché)
|
||||
}
|
||||
}
|
||||
|
||||
public static ApiRejection NotConfigured(string gameId, string what) => new(503, ApiErrorCodes.NotConfigured,
|
||||
$"{what} impossible pour « {gameId} » : manifest non configuré sur le serveur (LauncherApi:Games:{gameId}:ManifestFile).");
|
||||
}
|
||||
|
||||
/// <summary>Écriture refusée 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,58 @@
|
||||
{
|
||||
"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": ""
|
||||
},
|
||||
// Trousseau (phase 7) : vide = « keyring.json » à côté du catalogue (même dossier, déjà autorisé en écriture).
|
||||
"Keyring": {
|
||||
"PublicFile": "",
|
||||
"PublicUrl": ""
|
||||
},
|
||||
// Signature du contenu : true = catalogue et versions refusés s'ils ne sont pas signés (une fois les
|
||||
// launchers en mode « require », voir server/deploy/LISEZMOI.md, étape 3 quinquies).
|
||||
"ContentSigning": {
|
||||
"Require": false
|
||||
},
|
||||
// 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,
|
||||
// Morceaux de 4 Mo envoyés pendant une publication (à la place de RequestsPerMinute) : 1500 ≈ 100 Mo/s.
|
||||
"PublicationUploadsPerMinute": 1500,
|
||||
"AuthPerMinute": 20,
|
||||
"EnrollmentsPerHour": 10,
|
||||
"MaxActiveInvitations": 20,
|
||||
"MaxRequestBodyBytes": 65536
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
# 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.
|
||||
|
||||
## 3 quater. Publier une version depuis le launcher (VPS)
|
||||
|
||||
La page **Publier une version** du menu d'administration remplace l'envoi avec WinSCP : seuls les
|
||||
fichiers absents du serveur sont envoyés, l'envoi reprend après une coupure, et le manifest est mis
|
||||
en ligne en dernier. Prérequis : l'étape 3 ter (dossier du jeu déclaré dans `LauncherApi:Games`,
|
||||
complément `games.conf`, bit setgid).
|
||||
|
||||
1. nginx : ajoute la zone de limite des envois dans `/etc/nginx/conf.d/securite.conf`, à côté de
|
||||
`api_par_ip` (un envoi enchaîne plusieurs morceaux de 4 Mo par seconde ; avec la limite générale de
|
||||
l'API, il serait bridé et ferait des pauses) :
|
||||
|
||||
```
|
||||
limit_req_zone $binary_remote_addr zone=publication_par_ip:10m rate=25r/s;
|
||||
```
|
||||
|
||||
Puis colle le bloc `location ^~ /api/v1/publications/ { … }` de `server/deploy/nginx-publications.conf`
|
||||
dans le bloc HTTPS du site, à côté du bloc `/api/`, et recharge :
|
||||
|
||||
```
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
Seules ces routes acceptent des envois de 8 Mo ; le reste de l'API reste limité à 64 Ko. Vérifie
|
||||
aussi que le site contient toujours la règle `location ~ /\. { deny all; }` : les fichiers en cours
|
||||
d'envoi sont rangés dans un dossier caché (`.publish-…`) du dossier du jeu.
|
||||
2. Crée le dossier `objects` du jeu **et ses 256 sous-dossiers** (`00` à `ff`), avec le groupe et le bit
|
||||
setgid du dossier du jeu (sans risque s'ils existent déjà : leurs droits sont simplement remis en état) :
|
||||
|
||||
```
|
||||
JEU=/srv/exovival/www/exovivalzombie
|
||||
sudo install -d -m 2750 -o deploy -g www-data "$JEU/objects"
|
||||
for i in $(seq 0 255); do sudo install -d -m 2750 -o deploy -g www-data "$JEU/objects/$(printf %02x $i)"; done
|
||||
```
|
||||
|
||||
Pourquoi : systemd interdit au service de demander le bit setgid (`RestrictSUIDSGID=yes`, gardé). Un
|
||||
sous-dossier qui manquerait serait créé par le service en `drwxr-x---` (groupe `www-data`, lisible par
|
||||
nginx) mais **sans setgid** : un fichier déposé ensuite dedans **avec WinSCP** prendrait le groupe
|
||||
`deploy` et ne serait plus lisible par nginx. Relance la boucle si `stat` montre un `drwxr-x---`.
|
||||
3. Espace disque : l'envoi est stocké sur le même disque que le jeu jusqu'à la mise en ligne. Prévois la
|
||||
taille de l'archive et des nouveaux fichiers (le serveur refuse l'envoi s'il manque de place).
|
||||
4. Teste : lance le .bat de publication (ManifestBuilder) sur ton PC, puis menu d'administration ›
|
||||
**Publier une version** › « Choisir le manifest.json… » (dossier `--output`) › **Publier**.
|
||||
|
||||
> **Déjà fait avant la correction des pauses ?** Ajoute la ligne `limit_req_zone … publication_par_ip …`
|
||||
> ci-dessus, remplace dans le bloc `/api/v1/publications/` du site `limit_req zone=api_par_ip burst=20 nodelay;`
|
||||
> par `limit_req zone=publication_par_ip burst=50 nodelay;`, recharge nginx, puis mets à jour le serveur
|
||||
> (étape 8) : il a désormais sa propre limite pour les envois (`LauncherApi:Limits:PublicationUploadsPerMinute`,
|
||||
> 1500 par minute par défaut, rien à ajouter dans `appsettings.Production.json`).
|
||||
|
||||
Une publication interrompue (fermeture du launcher, coupure) reprend quand tu cliques de nouveau sur
|
||||
« Publier » avec le même dossier ; sans reprise, elle est supprimée au bout de 48 h. WinSCP reste
|
||||
possible, mais n'envoie pas une version avec les deux en même temps : le serveur refuse alors de
|
||||
mettre la sienne en ligne plutôt que d'écraser l'autre.
|
||||
|
||||
## 3 quinquies. Signer ce que reçoivent les joueurs (PC et VPS)
|
||||
|
||||
Sans signature, quelqu'un qui prendrait le contrôle du VPS pourrait distribuer un faux jeu. Avec la
|
||||
signature, chaque version mise en ligne (fichiers, archive, exécutable) et le catalogue portent la
|
||||
signature de l'administrateur qui les a publiés. Les launchers ne font confiance qu'aux clés du
|
||||
**trousseau**, lui-même signé **hors ligne** par la clé racine du launcher (`launcher-signing.key`, celle des
|
||||
mises à jour du launcher). Le VPS ne détient aucune de ces clés privées.
|
||||
|
||||
Prérequis : étapes 3 bis (catalogue : le trousseau est écrit dans le même dossier) et 3 quater.
|
||||
|
||||
1. **Serveur** : mets-le à jour (étape 8). Rien à ajouter dans `appsettings.Production.json` : le trousseau
|
||||
est écrit à côté du catalogue (`/srv/exovival/www/catalog/keyring.json`).
|
||||
2. **nginx** : ajoute le bloc `location = /catalog/keyring.json { … }` de `server/deploy/nginx-catalog.conf` à côté
|
||||
de celui du catalogue, puis `sudo nginx -t && sudo systemctl reload nginx`.
|
||||
3. **Trousseau** (PC) :
|
||||
1. menu d'administration › **Signature** › « Exporter la demande… » : enregistre `keyring-request-1.json`
|
||||
(les appareils actifs des administrateurs autorisés à publier) ;
|
||||
2. glisse ce fichier sur `tools\sign-keyring.bat` : la demande est comparée au trousseau signé précédent
|
||||
(`keyring-N.json` du même dossier) et chaque **NOUVELLE CLÉ** (ou autorisation élargie) est signalée.
|
||||
**Vérifie ces empreintes hors du serveur** : la demande vient du VPS, un serveur compromis pourrait y glisser
|
||||
sa propre clé. Fais lire à chaque personne l'empreinte de son appareil (page **Mes appareils**, calculée sur
|
||||
son PC) de vive voix ou par téléphone, et compare. Puis la phrase de passe de la clé racine → `keyring-N.json`
|
||||
(garde ce fichier dans le même dossier : il servira de référence la fois suivante) ;
|
||||
3. page **Signature** › « Publier le trousseau signé… » : le launcher vérifie la signature racine avant de l'envoyer.
|
||||
|
||||
Les noms des appareils (« Nicolas — PC fixe ») sont **publics** : le trousseau est lisible par tous.
|
||||
4. **Contenu déjà en ligne** :
|
||||
* version du jeu : page **Publier une version** › « Choisir le manifest.json… » du dossier de **la version
|
||||
en ligne** › « Signer la version en ligne » (le serveur vérifie que les fichiers sont identiques) ;
|
||||
* catalogue : page **Jeux** › « Publier » (il est signé à chaque publication).
|
||||
|
||||
La page **Signature** doit alors tout indiquer « valide pour les launchers ».
|
||||
5. **Launcher** : dans `src\GameLauncher\launcher.json`, décommente `"keyringUrl"` (adresse affichée par la page
|
||||
Signature) et `"contentSigning": "warn"`, puis publie le launcher (mise à jour signée habituelle). En « warn »,
|
||||
les problèmes sont seulement inscrits au journal du launcher (`%AppData%\<éditeur>\GameLauncher\logs\launcher.log`) : vérifie qu'il
|
||||
indique « Manifest de EZR … signé par … » et « Trousseau n° 1 vérifié ».
|
||||
6. **Protection complète** : passe à `"contentSigning": "require"` et publie le launcher : un contenu non signé,
|
||||
mal signé ou plus ancien que le dernier vu est alors refusé (le jeu installé reste jouable). Optionnel, sur le
|
||||
VPS : `"ContentSigning": { "Require": true }` dans la section `LauncherApi` d'`appsettings.Production.json`
|
||||
(puis `sudo systemctl restart launcher-api`) pour que le serveur refuse toute publication non signée.
|
||||
|
||||
Ensuite, les nouvelles versions et le catalogue sont signés automatiquement à leur publication. **À refaire
|
||||
(étape 3)** : à chaque nouvel administrateur ou nouvel appareil qui doit publier (avant sa première publication),
|
||||
et à chaque révocation (la page Signature signale le contenu signé par une clé qui va disparaître : re-signe-le
|
||||
d'abord). Une version envoyée avec WinSCP doit être signée ensuite (étape 4).
|
||||
|
||||
**Clé compromise** (appareil volé…) : révoque l'appareil, signe et publie un nouveau trousseau (étape 3), puis
|
||||
mets son numéro dans `"keyringMinSequence"` de `launcher.json` et publie le launcher. Sans cela, un launcher qui
|
||||
n'a encore jamais vu le nouveau trousseau (installation neuve, cache effacé) accepterait encore l'ancien,
|
||||
toujours signé par la clé racine, si un serveur compromis le lui présentait.
|
||||
|
||||
Limites :
|
||||
* les images du catalogue ne sont pas signées (un serveur compromis pourrait les remplacer, pas les liens ni les
|
||||
textes) ;
|
||||
* un launcher accepte tout trousseau signé par la clé racine dont le numéro est au moins `keyringMinSequence`
|
||||
et au moins celui du dernier trousseau qu'il a vu ;
|
||||
* un appareil ajouté au trousseau pendant qu'un launcher tourne est connu de ce launcher au plus tard 5 minutes
|
||||
après (nouveau téléchargement du trousseau) ou à son prochain démarrage.
|
||||
|
||||
La clé racine (`launcher-signing.key`) ne doit jamais aller sur le VPS ni dans Git, ni servir de clé
|
||||
d'administrateur (le menu d'administration la refuse, quel que soit le nom du fichier) : c'est elle qui protège
|
||||
à la fois les mises à jour du launcher et le trousseau.
|
||||
|
||||
## 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
|
||||
|
||||
Tout se fait dans le launcher (menu d'administration), sans ligne de commande :
|
||||
|
||||
1. **Toi** : page **Administrateurs** › « Inviter un administrateur » (nom, rôle : propriétaire,
|
||||
administrateur ou éditeur, voir `docs/ADMIN-ARCHITECTURE.md` § 7). Transmets le code affiché (valable
|
||||
24 h, usage unique, affiché une seule fois).
|
||||
2. **Paul** : launcher › menu d'administration › « J'ai une invitation », code collé ; il te communique
|
||||
**par téléphone ou message** l'empreinte affichée.
|
||||
3. **Toi** : page **Administrateurs** › « Actualiser » : le compte de Paul apparaît en premier, en
|
||||
attente ; saisis l'empreinte dictée sous son appareil, puis « Approuver ».
|
||||
|
||||
En ligne de commande, `tools\admin-key.bat` fait la même chose : `invite --api https://launcher.lanisys.fr/api/
|
||||
--name "Paul" --role editor`, puis `admins --api …` et `approve --api … --admin <id> --key-id <clé>
|
||||
--fingerprint "<empreinte>"`.
|
||||
|
||||
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 ce compte (page Administrateurs, ou `revoke-admin`) et recommence.
|
||||
|
||||
Nouveau PC pour un administrateur existant : **lui-même**, depuis un PC déjà enregistré, page **Mes
|
||||
appareils** › « Ajouter un appareil » ; sur le nouveau PC, « J'ai une invitation » ; puis, de retour sur
|
||||
l'ancien, approbation de l'appareil en attente avec l'empreinte affichée par le nouveau (en ligne de
|
||||
commande : `invite --for <son id>`, `enroll`, `approve`). 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 (notes de version, publications ; 20 derniers)
|
||||
├─ publications/ publications en cours (version, fichiers restant à envoyer)
|
||||
└─ 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é)
|
||||
```
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/bin/sh
|
||||
# =============================================================================
|
||||
# Installe ou met à jour la Launcher API sur le VPS (compte admin, avec sudo) :
|
||||
# sudo sh installer-api.sh /tmp/launcher-api.tar.gz <empreinte SHA-256>
|
||||
# L'empreinte est affichée par tools\publish-server.bat (fichier launcher-api.tar.gz.sha256) :
|
||||
# une archive modifiée pendant le transfert ou dans /tmp est refusée.
|
||||
#
|
||||
# • binaires dans /opt/launcher-api (propriétaire root : le service ne peut pas les modifier) ;
|
||||
# • service systemd « launcher-api » (compte deploy, voir launcher-api.service) ;
|
||||
# • appsettings.Production.json (réglages propres au VPS) conservé d'une version à l'autre ;
|
||||
# • en cas d'échec du démarrage, la version précédente est remise en place.
|
||||
# =============================================================================
|
||||
set -eu
|
||||
|
||||
ARCHIVE="${1:-}"
|
||||
EXPECTED_SHA256=$(printf '%s' "${2:-}" | tr 'A-F' 'a-f')
|
||||
INSTALL_DIR=/opt/launcher-api
|
||||
SERVICE=launcher-api
|
||||
SERVICE_USER=deploy
|
||||
|
||||
fail() { echo "ERREUR : $*" >&2; exit 1; }
|
||||
|
||||
[ "$(id -u)" -eq 0 ] || fail "lancez ce script avec sudo."
|
||||
[ -n "$ARCHIVE" ] && [ -f "$ARCHIVE" ] || fail "utilisation : sudo sh installer-api.sh /tmp/launcher-api.tar.gz <empreinte SHA-256>"
|
||||
printf '%s' "$EXPECTED_SHA256" | grep -Eq '^[0-9a-f]{64}$' || fail "empreinte SHA-256 manquante ou invalide (voir launcher-api.tar.gz.sha256)."
|
||||
id "$SERVICE_USER" >/dev/null 2>&1 || fail "le compte $SERVICE_USER n'existe pas (guide du VPS, étape 9)."
|
||||
command -v curl >/dev/null 2>&1 || fail "curl est nécessaire (sudo apt install -y curl)."
|
||||
|
||||
TMPROOT=$(mktemp -d)
|
||||
trap 'rm -rf "$TMPROOT"' EXIT
|
||||
# Copie privée de l'archive (dossier 700), PUIS vérification : plus rien ne peut la modifier entre-temps.
|
||||
cp "$ARCHIVE" "$TMPROOT/archive.tar.gz"
|
||||
ACTUAL_SHA256=$(sha256sum "$TMPROOT/archive.tar.gz" | cut -d' ' -f1)
|
||||
[ "$ACTUAL_SHA256" = "$EXPECTED_SHA256" ] || fail "empreinte différente : archive altérée ou incomplète. Rien n'a été modifié."
|
||||
WORK="$TMPROOT/files"
|
||||
mkdir "$WORK"
|
||||
tar -xzf "$TMPROOT/archive.tar.gz" -C "$WORK" --no-same-owner
|
||||
[ -f "$WORK/GameLauncher.Server" ] || fail "archive invalide : GameLauncher.Server absent."
|
||||
[ -f "$WORK/deploy/launcher-api.service" ] || fail "archive invalide : deploy/launcher-api.service absent."
|
||||
|
||||
echo "== Préparation de la nouvelle version"
|
||||
rm -rf "$INSTALL_DIR.new"
|
||||
mkdir -p "$INSTALL_DIR.new"
|
||||
cp -a "$WORK/." "$INSTALL_DIR.new/"
|
||||
rm -rf "$INSTALL_DIR.new/deploy"
|
||||
if [ -f "$INSTALL_DIR/appsettings.Production.json" ]; then
|
||||
cp -a "$INSTALL_DIR/appsettings.Production.json" "$INSTALL_DIR.new/"
|
||||
fi
|
||||
chown -R root:root "$INSTALL_DIR.new"
|
||||
find "$INSTALL_DIR.new" -type d -exec chmod 755 {} +
|
||||
find "$INSTALL_DIR.new" -type f -exec chmod 644 {} +
|
||||
chmod 755 "$INSTALL_DIR.new/GameLauncher.Server"
|
||||
|
||||
echo "== Installation du service"
|
||||
UNIT=/etc/systemd/system/$SERVICE.service
|
||||
rm -f "$UNIT.old"
|
||||
[ -f "$UNIT" ] && cp -a "$UNIT" "$UNIT.old"
|
||||
systemctl stop $SERVICE 2>/dev/null || true
|
||||
install -m 644 -o root -g root "$WORK/deploy/launcher-api.service" "$UNIT"
|
||||
systemctl daemon-reload
|
||||
|
||||
rm -rf "$INSTALL_DIR.old"
|
||||
[ -d "$INSTALL_DIR" ] && mv "$INSTALL_DIR" "$INSTALL_DIR.old"
|
||||
mv "$INSTALL_DIR.new" "$INSTALL_DIR"
|
||||
systemctl enable --now $SERVICE
|
||||
|
||||
echo "== Vérification"
|
||||
OK=0
|
||||
for i in 1 2 3 4 5 6 7 8 9 10; do
|
||||
if curl -fsS http://127.0.0.1:5080/api/v1/health >/dev/null 2>&1; then OK=1; break; fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [ "$OK" -ne 1 ]; then
|
||||
echo "La nouvelle version ne répond pas. Dernières lignes du journal :" >&2
|
||||
journalctl -u $SERVICE -n 30 --no-pager >&2 || true
|
||||
if [ -d "$INSTALL_DIR.old" ]; then
|
||||
echo "Retour à la version précédente." >&2
|
||||
systemctl stop $SERVICE || true
|
||||
rm -rf "$INSTALL_DIR.failed"
|
||||
mv "$INSTALL_DIR" "$INSTALL_DIR.failed"
|
||||
mv "$INSTALL_DIR.old" "$INSTALL_DIR"
|
||||
[ -f "$UNIT.old" ] && cp -a "$UNIT.old" "$UNIT"
|
||||
systemctl daemon-reload
|
||||
systemctl start $SERVICE || true
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Launcher API opérationnelle :"
|
||||
curl -fsS http://127.0.0.1:5080/api/v1/health; echo
|
||||
echo "Journal : journalctl -u $SERVICE -f"
|
||||
echo "Premier propriétaire (une seule fois) : voir LISEZMOI.md, étape 5."
|
||||
@@ -0,0 +1,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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,34 @@
|
||||
# =============================================================================
|
||||
# Launcher API derrière nginx — DEUX ajouts (voir LISEZMOI.md, étape 3) :
|
||||
#
|
||||
# 1) Dans /etc/nginx/conf.d/securite.conf (contexte « http », à côté de limit_conn_zone) :
|
||||
#
|
||||
# limit_req_zone $binary_remote_addr zone=api_par_ip:10m rate=5r/s;
|
||||
#
|
||||
# 2) Dans le bloc HTTPS (port 443, créé par certbot) du site
|
||||
# /etc/nginx/sites-available/dl-exovival, AVANT « location / { … } » :
|
||||
# (le bloc ci-dessous)
|
||||
#
|
||||
# Puis : sudo nginx -t && sudo systemctl reload nginx
|
||||
# =============================================================================
|
||||
|
||||
# « ^~ » : aucune autre règle (manifest, objects, .zip…) ne s'applique sous /api/.
|
||||
location ^~ /api/ {
|
||||
limit_req zone=api_par_ip burst=20 nodelay;
|
||||
limit_req_status 429;
|
||||
|
||||
# Requêtes d'administration : petites. (La publication de versions, en phase 5, aura sa propre règle.)
|
||||
client_max_body_size 64k;
|
||||
|
||||
proxy_pass http://127.0.0.1:5080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
# REMPLACE l'en-tête (au lieu d'ajouter à celui du client) : l'adresse IP transmise ne peut pas être falsifiée.
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
# =============================================================================
|
||||
# Catalogue des jeux et trousseau (écrits par la Launcher API) — voir LISEZMOI.md, étapes 3 bis et 3 quinquies :
|
||||
#
|
||||
# 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;
|
||||
}
|
||||
|
||||
# Trousseau (phase 7) : clés autorisées à signer versions et catalogue, signé par la clé racine du launcher.
|
||||
location = /catalog/keyring.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,39 @@
|
||||
# =============================================================================
|
||||
# Publication d'une version depuis le launcher — deux ajouts (voir LISEZMOI.md, étape 3 quater) :
|
||||
#
|
||||
# 1) Dans /etc/nginx/conf.d/securite.conf, à côté de la zone « api_par_ip » :
|
||||
#
|
||||
# limit_req_zone $binary_remote_addr zone=publication_par_ip:10m rate=25r/s;
|
||||
#
|
||||
# Un envoi enchaîne plusieurs morceaux de 4 Mo par seconde : la zone « api_par_ip » (5 par seconde)
|
||||
# briderait l'envoi vers 20 Mo/s et provoquerait des pauses. 25 par seconde ≈ 100 Mo/s ; ces routes
|
||||
# exigent une session d'administrateur et le droit de publier.
|
||||
#
|
||||
# 2) Dans le bloc HTTPS (port 443) du site /etc/nginx/sites-available/dl-exovival, à côté du bloc
|
||||
# « location ^~ /api/ { … } » (nginx-api.conf) : (le bloc ci-dessous)
|
||||
#
|
||||
# Puis : sudo nginx -t && sudo systemctl reload nginx
|
||||
#
|
||||
# Seules ces routes acceptent de gros envois (morceaux de 4 Mo, 8 Mo au plus) ; toutes les autres
|
||||
# routes de l'API restent limitées à 64 Ko. Le préfixe le plus long l'emporte sur « /api/ ».
|
||||
# Les fichiers en cours d'envoi sont rangés dans un dossier caché (.publish-…) du dossier du jeu :
|
||||
# la règle « location ~ /\. { deny all; } » du site doit être présente (elle l'est dans le guide).
|
||||
# =============================================================================
|
||||
|
||||
location ^~ /api/v1/publications/ {
|
||||
limit_req zone=publication_par_ip burst=50 nodelay;
|
||||
limit_req_status 429;
|
||||
|
||||
client_max_body_size 9m;
|
||||
|
||||
proxy_pass http://127.0.0.1:5080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_send_timeout 180s;
|
||||
proxy_read_timeout 180s;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Contracts.Publishing;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
|
||||
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;
|
||||
|
||||
/// <summary>Clé publique de CET appareil (SPKI), lue sur le PC : son empreinte ne dépend pas du serveur.</summary>
|
||||
public byte[] PublicKey => _signer.PublicKey;
|
||||
|
||||
/// <summary>Empreinte de la clé de cet appareil, calculée sur le PC (à dicter pour vérifier le trousseau).</summary>
|
||||
public string LocalFingerprint => AdminKeys.ComputeFingerprint(_signer.PublicKey);
|
||||
|
||||
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);
|
||||
|
||||
/// <summary>Compte de l'administrateur connecté (ses appareils, ses invitations « nouvel appareil »).</summary>
|
||||
public Task<MyAccount> GetMyAccountAsync(CancellationToken ct = default) => GetAsync<MyAccount>(ApiRoutes.MyAccount, 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);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Publication d'une version
|
||||
|
||||
/// <summary>Délai d'une requête d'envoi de fichier (morceau de 4 Mo sur une connexion lente).</summary>
|
||||
public static readonly TimeSpan UploadTimeout = TimeSpan.FromMinutes(3);
|
||||
|
||||
public Task<PublicationState> StartPublicationAsync(string gameId, PublicationStartRequest request, CancellationToken ct = default) =>
|
||||
SendSignedAsync<PublicationState>(HttpMethod.Post, ApiRoutes.PublicationStart(CheckGameId(gameId)), request, ct);
|
||||
|
||||
public Task<PublicationState> GetPublicationAsync(string id, CancellationToken ct = default) =>
|
||||
GetAsync<PublicationState>(ApiRoutes.Publication(CheckPublicationId(id)), ct);
|
||||
|
||||
public Task CancelPublicationAsync(string id, CancellationToken ct = default) =>
|
||||
SendSignedAsync<NoContent>(HttpMethod.Delete, ApiRoutes.Publication(CheckPublicationId(id)), null, ct);
|
||||
|
||||
public Task<ChunkResult> UploadManifestAsync(string id, long offset, byte[] data, CancellationToken ct = default) =>
|
||||
SendSignedBytesAsync<ChunkResult>(HttpMethod.Put, ApiRoutes.PublicationManifest(CheckPublicationId(id), offset), data,
|
||||
PublishingLimits.BinaryContentType, UploadTimeout, ct);
|
||||
|
||||
public Task<ChunkResult> UploadArchiveAsync(string id, long offset, byte[] data, CancellationToken ct = default) =>
|
||||
SendSignedBytesAsync<ChunkResult>(HttpMethod.Put, ApiRoutes.PublicationArchive(CheckPublicationId(id), offset), data,
|
||||
PublishingLimits.BinaryContentType, UploadTimeout, ct);
|
||||
|
||||
public Task<ChunkResult> UploadObjectAsync(string id, string sha256, long offset, byte[] data, CancellationToken ct = default) =>
|
||||
SendSignedBytesAsync<ChunkResult>(HttpMethod.Put,
|
||||
ApiRoutes.PublicationObject(CheckPublicationId(id), PublishingLimits.IsValidSha256(sha256) ? sha256.ToLowerInvariant()
|
||||
: throw new ArgumentException("Empreinte invalide.", nameof(sha256)), offset), data,
|
||||
PublishingLimits.BinaryContentType, UploadTimeout, ct);
|
||||
|
||||
public Task<ChunkResult> UploadObjectBatchAsync(string id, byte[] batch, CancellationToken ct = default) =>
|
||||
SendSignedBytesAsync<ChunkResult>(HttpMethod.Post, ApiRoutes.PublicationObjects(CheckPublicationId(id)), batch,
|
||||
PublishingLimits.ObjectBatchContentType, UploadTimeout, ct);
|
||||
|
||||
/// <summary>Mise en ligne (délai long : renommages et écriture du manifest sur le serveur).</summary>
|
||||
/// <param name="signature">Signature du contenu du manifest (phase 7), ou null (version non signée).</param>
|
||||
public Task<PublicationResult> CommitPublicationAsync(string id, ContentSignature? signature = null, CancellationToken ct = default) =>
|
||||
SendCoreAsync<PublicationResult>(HttpMethod.Post, ApiRoutes.PublicationCommit(CheckPublicationId(id)),
|
||||
signature is null ? null : JsonSerializer.SerializeToUtf8Bytes(new PublicationCommitRequest { Signature = signature }, ApiJson.Options),
|
||||
signed: true, ct, timeout: UploadTimeout);
|
||||
|
||||
// ------------------------------------------------------------------ Signature du contenu (phase 7)
|
||||
|
||||
/// <summary>
|
||||
/// Signe un contenu servi aux joueurs avec la clé de cette session (date : heure du serveur, à la seconde).
|
||||
/// La clé privée ne quitte jamais le PC (ni la puce TPM) : seul le condensé calculé ici est signé.
|
||||
/// </summary>
|
||||
public async Task<ContentSignature> SignContentAsync(string kind, string id, string digest, CancellationToken ct = default)
|
||||
{
|
||||
var signedAt = ContentSigning.Truncate(DateTimeOffset.UtcNow + _clockOffset);
|
||||
var message = ContentSigning.Message(kind, id, signedAt, _signer.KeyId, digest);
|
||||
var signature = await _signer.SignAsync(message, ct).ConfigureAwait(false);
|
||||
return new ContentSignature { KeyId = _signer.KeyId, SignedAt = signedAt, Value = Convert.ToBase64String(signature) };
|
||||
}
|
||||
|
||||
public Task<SigningState> GetSigningStateAsync(CancellationToken ct = default) => GetAsync<SigningState>(ApiRoutes.Signing, ct);
|
||||
|
||||
/// <summary>Publie un trousseau signé par la clé racine (le serveur contrôle la structure et le numéro).</summary>
|
||||
public Task<KeyringDocument> PublishKeyringAsync(KeyringDocument keyring, CancellationToken ct = default) =>
|
||||
SendSignedAsync<KeyringDocument>(HttpMethod.Put, ApiRoutes.Keyring, keyring, ct);
|
||||
|
||||
public Task<ManifestSignatureState> GetManifestSignatureAsync(string gameId, CancellationToken ct = default) =>
|
||||
GetAsync<ManifestSignatureState>(ApiRoutes.ManifestSignature(CheckGameId(gameId)), ct);
|
||||
|
||||
/// <summary>Signe la version en ligne (le serveur vérifie qu'elle a exactement le contenu signé).</summary>
|
||||
public Task<ManifestSignatureState> SignOnlineManifestAsync(string gameId, ManifestSignRequest request, CancellationToken ct = default) =>
|
||||
SendSignedAsync<ManifestSignatureState>(HttpMethod.Post, ApiRoutes.ManifestSignature(CheckGameId(gameId)), request, ct);
|
||||
|
||||
private static string CheckPublicationId(string id) =>
|
||||
PublishingLimits.IsValidPublicationId(id) ? id : throw new ArgumentException("Identifiant de publication invalide.", nameof(id));
|
||||
|
||||
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);
|
||||
|
||||
/// <summary>Requête qui modifie avec un corps binaire (morceau de fichier), signée par la clé.</summary>
|
||||
public Task<T> SendSignedBytesAsync<T>(HttpMethod method, string route, byte[] body, string contentType, TimeSpan timeout,
|
||||
CancellationToken ct = default) =>
|
||||
SendCoreAsync<T>(method, route, body, signed: true, ct, contentType, timeout);
|
||||
|
||||
private async Task<T> SendCoreAsync<T>(HttpMethod method, string route, byte[]? body, bool signed, CancellationToken ct,
|
||||
string contentType = "application/json", TimeSpan? timeout = null)
|
||||
{
|
||||
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, contentType, timeout).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,24 @@
|
||||
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; }
|
||||
|
||||
/// <summary>Délai annoncé par le serveur avant un nouvel essai (en-tête Retry-After), s'il y en a un.</summary>
|
||||
public TimeSpan? RetryAfter { get; init; }
|
||||
|
||||
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,263 @@
|
||||
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)
|
||||
{
|
||||
// Délai propre à chaque requête (SendAsync) : 30 s par défaut, plus pour l'envoi de fichiers.
|
||||
Timeout = Timeout.InfiniteTimeSpan
|
||||
};
|
||||
_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,
|
||||
string contentType = "application/json", TimeSpan? timeout = null)
|
||||
{
|
||||
using var request = new HttpRequestMessage(method, Resolve(route));
|
||||
if (body is not null)
|
||||
{
|
||||
request.Content = new ByteArrayContent(body);
|
||||
request.Content.Headers.ContentType = contentType == "application/json"
|
||||
? new MediaTypeHeaderValue(contentType) { CharSet = "utf-8" }
|
||||
: new MediaTypeHeaderValue(contentType);
|
||||
}
|
||||
prepare?.Invoke(request);
|
||||
|
||||
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
deadline.CancelAfter(timeout ?? RequestTimeout);
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, deadline.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
throw new ApiException(0, "unreachable", $"Serveur d'administration injoignable ({ex.Message}).", ex);
|
||||
}
|
||||
catch (OperationCanceledException ex) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
throw new ApiException(0, "timeout", "Le serveur d'administration ne répond pas.", ex);
|
||||
}
|
||||
|
||||
using (response)
|
||||
{
|
||||
byte[] bytes;
|
||||
try
|
||||
{
|
||||
bytes = await ReadBoundedAsync(response, deadline.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException ex) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
throw new ApiException(0, "timeout", "Le serveur d'administration ne répond pas.", ex);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
throw new ApiException(0, "unreachable", $"Connexion au serveur d'administration interrompue ({ex.Message}).", ex);
|
||||
}
|
||||
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;
|
||||
var retryAfter = RetryAfterOf(response);
|
||||
try
|
||||
{
|
||||
if (body.Length > 0 && JsonSerializer.Deserialize<ApiError>(body, ApiJson.Options) is { Message.Length: > 0 } error)
|
||||
return new ApiException(status, error.Code, error.Message) { RetryAfter = retryAfter };
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// réponse d'erreur non JSON (nginx, proxy…)
|
||||
}
|
||||
var (code, message) = status switch
|
||||
{
|
||||
401 => (ApiErrorCodes.Unauthorized, "Session expirée : reconnectez-vous."),
|
||||
403 => (ApiErrorCodes.Forbidden, "Action non autorisée."),
|
||||
404 => (ApiErrorCodes.NotFound, "Adresse inconnue sur le serveur (version de l'API ?)."),
|
||||
413 => (ApiErrorCodes.InvalidRequest,
|
||||
"Requête trop volumineuse pour le serveur (envoi de fichiers : vérifiez nginx-publications.conf sur le VPS)."),
|
||||
429 => (ApiErrorCodes.RateLimited, "Trop de requêtes : patientez une minute."),
|
||||
>= 500 => (ApiErrorCodes.Error, $"Erreur du serveur ({status})."),
|
||||
_ => (ApiErrorCodes.Error, $"Réponse inattendue du serveur ({status}).")
|
||||
};
|
||||
return new ApiException(status, code, message) { RetryAfter = retryAfter };
|
||||
}
|
||||
|
||||
/// <summary>En-tête Retry-After (en secondes ou en date) ; absent ou incohérent : null.</summary>
|
||||
private static TimeSpan? RetryAfterOf(HttpResponseMessage response)
|
||||
{
|
||||
var header = response.Headers.RetryAfter;
|
||||
var delay = header?.Delta ?? (header?.Date is { } date ? date - DateTimeOffset.UtcNow : null);
|
||||
return delay is { } value && value >= TimeSpan.Zero ? value : null;
|
||||
}
|
||||
|
||||
/// <summary>Le serveur doit annoncer la même origine que celle configurée (sinon les signatures seraient refusées).</summary>
|
||||
private void EnsureSameOrigin(string serverOrigin)
|
||||
{
|
||||
if (!string.Equals(serverOrigin, Origin, StringComparison.OrdinalIgnoreCase))
|
||||
throw new ApiException(0, "origin_mismatch",
|
||||
$"Le serveur se présente comme « {serverOrigin} » au lieu de « {Origin} » : configuration à vérifier (PublicOrigin).");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_ownsHttp) _http.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Réponse sans contenu (204).</summary>
|
||||
public sealed class NoContent
|
||||
{
|
||||
public static readonly NoContent Value = new();
|
||||
private NoContent() { }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace GameLauncher.Contracts;
|
||||
|
||||
/// <summary>Options JSON communes au launcher et au serveur (strictes : ni commentaires, ni virgules finales).</summary>
|
||||
public static class ApiJson
|
||||
{
|
||||
public static readonly JsonSerializerOptions Options = Create();
|
||||
|
||||
/// <summary>Taille maximale d'une réponse JSON lue par le client (hors transferts de fichiers).</summary>
|
||||
public const int MaxResponseBytes = 1024 * 1024;
|
||||
|
||||
public static JsonSerializerOptions Create() => new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
ReadCommentHandling = JsonCommentHandling.Disallow,
|
||||
AllowTrailingCommas = false,
|
||||
MaxDepth = 32,
|
||||
NumberHandling = JsonNumberHandling.Strict
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
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";
|
||||
|
||||
/// <summary>Compte de l'administrateur connecté (ses appareils, ses invitations « nouvel appareil »).</summary>
|
||||
public const string MyAccount = "v1/admins/me";
|
||||
|
||||
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}";
|
||||
|
||||
// ------------------------------------------------------------------ Signature du contenu (phase 7)
|
||||
/// <summary>État des signatures (trousseau publié, manifests, catalogue) : propriétaires.</summary>
|
||||
public const string Signing = "v1/signing";
|
||||
|
||||
/// <summary>Publication d'un trousseau signé par la clé racine : propriétaires.</summary>
|
||||
public const string Keyring = "v1/keyring";
|
||||
|
||||
public const string ManifestSignaturePattern = "v1/games/{gameId}/signature";
|
||||
|
||||
public static string ManifestSignature(string gameId) => $"v1/games/{gameId}/signature";
|
||||
|
||||
// ------------------------------------------------------------------ Publication d'une version
|
||||
public const string PublicationStartPattern = "v1/games/{gameId}/publications";
|
||||
public const string PublicationPattern = "v1/publications/{id}";
|
||||
public const string PublicationManifestPattern = "v1/publications/{id}/manifest";
|
||||
public const string PublicationArchivePattern = "v1/publications/{id}/archive";
|
||||
public const string PublicationObjectsPattern = "v1/publications/{id}/objects";
|
||||
public const string PublicationObjectPattern = "v1/publications/{id}/objects/{sha256}";
|
||||
public const string PublicationCommitPattern = "v1/publications/{id}/commit";
|
||||
|
||||
/// <summary>Préfixe des routes d'envoi (limite de taille propre, voir nginx-publications.conf).</summary>
|
||||
public const string PublicationsPrefix = "v1/publications/";
|
||||
|
||||
public static string PublicationStart(string gameId) => $"v1/games/{gameId}/publications";
|
||||
public static string Publication(string id) => $"v1/publications/{id}";
|
||||
public static string PublicationManifest(string id, long offset) => $"v1/publications/{id}/manifest?offset={offset}";
|
||||
public static string PublicationArchive(string id, long offset) => $"v1/publications/{id}/archive?offset={offset}";
|
||||
public static string PublicationObjects(string id) => $"v1/publications/{id}/objects";
|
||||
public static string PublicationObject(string id, string sha256, long offset) => $"v1/publications/{id}/objects/{sha256}?offset={offset}";
|
||||
public static string PublicationCommit(string id) => $"v1/publications/{id}/commit";
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Text;
|
||||
|
||||
namespace GameLauncher.Contracts;
|
||||
|
||||
/// <summary>Contrôle des textes courts saisis dans le menu d'administration (noms, libellés).</summary>
|
||||
public static class ApiText
|
||||
{
|
||||
public const int MaxDisplayNameLength = 64;
|
||||
public const int MaxKeyLabelLength = 64;
|
||||
public const int MaxClientNameLength = 96;
|
||||
|
||||
/// <summary>
|
||||
/// Texte d'une ligne : espaces normalisés, sans caractère de contrôle ni de mise en forme
|
||||
/// bidirectionnelle. Null si vide ou trop long (après nettoyage).
|
||||
/// </summary>
|
||||
public static string? CleanLine(string? value, int maxLength)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var sb = new StringBuilder(value.Length);
|
||||
var previousSpace = false;
|
||||
foreach (var c in value.Trim())
|
||||
{
|
||||
if (char.IsWhiteSpace(c))
|
||||
{
|
||||
if (!previousSpace) sb.Append(' ');
|
||||
previousSpace = true;
|
||||
continue;
|
||||
}
|
||||
if (char.IsControl(c) || IsFormatting(c)) return null;
|
||||
sb.Append(c);
|
||||
previousSpace = false;
|
||||
}
|
||||
var text = sb.ToString();
|
||||
return text.Length == 0 || text.Length > maxLength ? null : text;
|
||||
}
|
||||
|
||||
/// <summary>Caractères invisibles de mise en forme (bidirectionnels, largeur nulle…).</summary>
|
||||
private static bool IsFormatting(char c) =>
|
||||
char.GetUnicodeCategory(c) == System.Globalization.UnicodeCategory.Format;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace GameLauncher.Contracts;
|
||||
|
||||
/// <summary>Base64 « URL » sans remplissage (nonces, jetons).</summary>
|
||||
public static class Base64Url
|
||||
{
|
||||
public static string Encode(ReadOnlySpan<byte> data) =>
|
||||
Convert.ToBase64String(data).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
|
||||
public static bool TryDecode(string? text, out byte[] data)
|
||||
{
|
||||
data = Array.Empty<byte>();
|
||||
if (string.IsNullOrEmpty(text) || text.Length > 4096) return false;
|
||||
foreach (var c in text)
|
||||
if (!(c is >= 'A' and <= 'Z' or >= 'a' and <= 'z' or >= '0' and <= '9' or '-' or '_')) return false;
|
||||
|
||||
var s = text.Replace('-', '+').Replace('_', '/');
|
||||
switch (s.Length % 4)
|
||||
{
|
||||
case 2: s += "=="; break;
|
||||
case 3: s += "="; break;
|
||||
case 1: return false;
|
||||
}
|
||||
try
|
||||
{
|
||||
data = Convert.FromBase64String(s);
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using GameLauncher.Contracts.Signing;
|
||||
|
||||
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>
|
||||
/// Signature de l'administrateur qui a publié (phase 7) : couvre tout sauf la révision et la date
|
||||
/// (<see cref="ContentSigning.CatalogDigest"/>). Ignorée par les launchers antérieurs.
|
||||
/// </summary>
|
||||
public ContentSignature? Signature { get; init; }
|
||||
}
|
||||
|
||||
/// <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] + "…";
|
||||
}
|
||||
+73
-4
@@ -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,86 @@
|
||||
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; }
|
||||
|
||||
/// <summary>
|
||||
/// Clé publique (SPKI, Base64) des clés ACTIVES seulement (trousseau de la phase 7). Absente pour une clé en
|
||||
/// attente (on pourrait en déduire l'empreinte) ou révoquée.
|
||||
/// </summary>
|
||||
public string? PublicKey { 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>();
|
||||
}
|
||||
|
||||
/// <summary>Compte de l'administrateur connecté : ses appareils (clés) et les invitations « nouvel appareil » en cours.</summary>
|
||||
public sealed record MyAccount
|
||||
{
|
||||
public AdminSummary Admin { get; init; } = new();
|
||||
public IReadOnlyList<InvitationSummary> Invitations { get; init; } = Array.Empty<InvitationSummary>();
|
||||
}
|
||||
|
||||
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; }
|
||||
|
||||
/// <summary>Administrateur qui a créé l'invitation (null : auteur inconnu).</summary>
|
||||
public Guid? CreatedById { 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,71 @@
|
||||
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";
|
||||
|
||||
/// <summary>Envoi d'un morceau à une position qui n'est pas la fin de ce que le serveur a reçu.</summary>
|
||||
public const string OffsetMismatch = "offset_mismatch";
|
||||
|
||||
/// <summary>Une publication de ce jeu est en cours (autre administrateur).</summary>
|
||||
public const string PublicationInProgress = "publication_in_progress";
|
||||
|
||||
/// <summary>Espace disque insuffisant sur le serveur.</summary>
|
||||
public const string InsufficientStorage = "insufficient_storage";
|
||||
}
|
||||
@@ -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,207 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace GameLauncher.Contracts.Publishing;
|
||||
|
||||
/// <summary>Ce que contient un manifest.json contrôlé.</summary>
|
||||
/// <param name="Objects">Objets à servir (empreinte en minuscules → taille), une fois chacun.</param>
|
||||
public sealed record ManifestSummary(
|
||||
string GameId,
|
||||
string Version,
|
||||
string Executable,
|
||||
string ArchiveName,
|
||||
long ArchiveSize,
|
||||
string ArchiveSha256,
|
||||
IReadOnlyDictionary<string, long> Objects,
|
||||
int FileCount,
|
||||
long TotalSize,
|
||||
string? NotesTitle);
|
||||
|
||||
/// <summary>
|
||||
/// Contrôle d'un manifest.json produit par ManifestBuilder, AVANT de le publier : par le launcher
|
||||
/// (dossier choisi) et par le serveur (manifest reçu). Mêmes règles des deux côtés ; le serveur ne fait
|
||||
/// confiance à rien de ce qu'il reçoit.
|
||||
/// • identifiant de jeu et version attendus ;
|
||||
/// • archive : simple nom de fichier .zip (jamais un chemin), taille et empreinte valides ;
|
||||
/// • objets dans « objects/ » (seul dossier écrit par la publication) ;
|
||||
/// • chemins des fichiers relatifs et sûrs (ni « .. », ni chemin absolu, ni « : »), sans doublon ;
|
||||
/// • exécutable présent parmi les fichiers ; tailles et nombre de fichiers bornés.
|
||||
/// </summary>
|
||||
public static partial class ManifestCheck
|
||||
{
|
||||
[GeneratedRegex(@"\A[A-Za-z0-9._+\-]{1,64}\z")]
|
||||
private static partial Regex VersionRegex();
|
||||
|
||||
[GeneratedRegex(@"\A[A-Za-z0-9][A-Za-z0-9._+\-]{0,127}\.zip\z", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex ArchiveNameRegex();
|
||||
|
||||
private static readonly JsonDocumentOptions ReadOptions = new()
|
||||
{
|
||||
CommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
MaxDepth = 64
|
||||
};
|
||||
|
||||
public static bool IsValidVersion(string? version) => version is not null && VersionRegex().IsMatch(version);
|
||||
|
||||
/// <summary>Contrôle ; null (et <paramref name="errors"/> rempli) si le manifest n'est pas publiable.</summary>
|
||||
public static ManifestSummary? Check(ReadOnlySpan<byte> json, string expectedGameId, string? expectedVersion, List<string> errors)
|
||||
{
|
||||
if (json.StartsWith(new byte[] { 0xEF, 0xBB, 0xBF })) json = json[3..];
|
||||
if (json.Length > PublishingLimits.MaxManifestBytes)
|
||||
{
|
||||
errors.Add("Manifest trop volumineux.");
|
||||
return null;
|
||||
}
|
||||
|
||||
JsonDocument document;
|
||||
try
|
||||
{
|
||||
document = JsonDocument.Parse(json.ToArray(), ReadOptions);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
errors.Add($"Manifest illisible : {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
|
||||
using (document)
|
||||
{
|
||||
var root = document.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
errors.Add("Manifest illisible : objet JSON attendu.");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Get(root, "formatVersion") is { } format && (format.ValueKind != JsonValueKind.Number || format.GetDouble() != 1))
|
||||
errors.Add("Format de manifest non pris en charge (formatVersion 1 attendu).");
|
||||
|
||||
var gameId = Text(root, "gameId");
|
||||
if (!string.Equals(gameId, expectedGameId, StringComparison.OrdinalIgnoreCase))
|
||||
errors.Add($"Ce manifest est celui du jeu « {gameId} », pas de « {expectedGameId} ».");
|
||||
|
||||
var version = Text(root, "version");
|
||||
if (!IsValidVersion(version))
|
||||
errors.Add($"Version « {version} » invalide (lettres, chiffres, « . », « _ », « + », « - »).");
|
||||
else if (expectedVersion is not null && !string.Equals(version, expectedVersion, StringComparison.Ordinal))
|
||||
errors.Add($"Le manifest est celui de la version {version}, pas {expectedVersion}.");
|
||||
|
||||
var objectsBase = Text(root, "objectsBaseUrl");
|
||||
if (!string.IsNullOrEmpty(objectsBase) && objectsBase is not ("objects/" or "objects"))
|
||||
errors.Add($"« objectsBaseUrl » doit être « objects/ » (manifest produit par ManifestBuilder), pas « {objectsBase} ».");
|
||||
|
||||
// Archive (installation initiale)
|
||||
string archiveName = "", archiveSha = "";
|
||||
long archiveSize = 0;
|
||||
if (Get(root, "archive") is { ValueKind: JsonValueKind.Object } archive)
|
||||
{
|
||||
archiveName = Text(archive, "url") ?? "";
|
||||
archiveSha = (Text(archive, "sha256") ?? "").ToLowerInvariant();
|
||||
archiveSize = Number(archive, "size");
|
||||
if (!ArchiveNameRegex().IsMatch(archiveName))
|
||||
errors.Add($"Archive « {archiveName} » : simple nom de fichier .zip attendu (à côté du manifest).");
|
||||
if (!PublishingLimits.IsValidSha256(archiveSha)) errors.Add("Archive : empreinte SHA-256 invalide.");
|
||||
if (archiveSize <= 0 || archiveSize > PublishingLimits.MaxArchiveBytes) errors.Add("Archive : taille invalide.");
|
||||
}
|
||||
else
|
||||
{
|
||||
errors.Add("Archive absente du manifest.");
|
||||
}
|
||||
|
||||
// Fichiers du jeu
|
||||
var objects = new Dictionary<string, long>(StringComparer.Ordinal);
|
||||
var paths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
long totalSize = 0;
|
||||
var fileCount = 0;
|
||||
if (Get(root, "files") is { ValueKind: JsonValueKind.Array } files)
|
||||
{
|
||||
fileCount = files.GetArrayLength();
|
||||
if (fileCount == 0) errors.Add("Aucun fichier dans le manifest.");
|
||||
if (fileCount > PublishingLimits.MaxFiles) errors.Add($"Trop de fichiers ({fileCount} ; {PublishingLimits.MaxFiles} au plus).");
|
||||
else
|
||||
{
|
||||
var index = 0;
|
||||
foreach (var file in files.EnumerateArray())
|
||||
{
|
||||
index++;
|
||||
if (errors.Count > 20) break;
|
||||
if (file.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
errors.Add($"Fichier n° {index} invalide.");
|
||||
continue;
|
||||
}
|
||||
var path = Text(file, "path") ?? "";
|
||||
var sha = (Text(file, "sha256") ?? "").ToLowerInvariant();
|
||||
var size = Number(file, "size");
|
||||
if (!IsSafeRelativePath(path)) errors.Add($"Chemin non autorisé : « {path} ».");
|
||||
else if (!paths.Add(path.Replace('\\', '/'))) errors.Add($"Fichier en double : « {path} ».");
|
||||
if (!PublishingLimits.IsValidSha256(sha)) errors.Add($"« {path} » : empreinte SHA-256 invalide.");
|
||||
else if (size < 0 || size > PublishingLimits.MaxObjectBytes) errors.Add($"« {path} » : taille invalide.");
|
||||
else if (objects.TryGetValue(sha, out var known) && known != size)
|
||||
errors.Add($"« {path} » : même empreinte qu'un autre fichier mais taille différente.");
|
||||
else
|
||||
{
|
||||
objects[sha] = size;
|
||||
totalSize += size;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
errors.Add("Liste des fichiers absente du manifest.");
|
||||
}
|
||||
|
||||
var executable = (Text(root, "executable") ?? "").Replace('\\', '/');
|
||||
if (string.IsNullOrWhiteSpace(executable)) errors.Add("Exécutable absent du manifest.");
|
||||
else if (!paths.Contains(executable)) errors.Add($"L'exécutable « {executable} » ne fait pas partie des fichiers.");
|
||||
|
||||
if (Get(root, "changelog") is { } changelog && changelog.ValueKind is not (JsonValueKind.Array or JsonValueKind.Null))
|
||||
errors.Add("Notes de version (« changelog ») : liste attendue.");
|
||||
|
||||
if (errors.Count > 0) return null;
|
||||
return new ManifestSummary(gameId!, version!, executable, archiveName, archiveSize, archiveSha, objects, fileCount, totalSize,
|
||||
NotesTitle(root, version!));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Chemin relatif sûr (mêmes règles que le launcher pour l'installation).</summary>
|
||||
public static bool IsSafeRelativePath(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || path.Length > 1024 || path.Any(char.IsControl)) return false;
|
||||
var p = path.Replace('\\', '/');
|
||||
if (p.StartsWith('/') || p.Contains(':')) return false;
|
||||
return p.Split('/').All(s => s.Length > 0 && s != "." && s != "..");
|
||||
}
|
||||
|
||||
/// <summary>Chemin d'un objet sous le dossier du manifest : « objects/ab/abcdef….bin ».</summary>
|
||||
public static string ObjectPath(string sha256)
|
||||
{
|
||||
var h = sha256.ToLowerInvariant();
|
||||
return $"objects/{h[..2]}/{h}.bin";
|
||||
}
|
||||
|
||||
private static string? NotesTitle(JsonElement root, string version)
|
||||
{
|
||||
if (Get(root, "changelog") is not { ValueKind: JsonValueKind.Array } changelog) return null;
|
||||
foreach (var entry in changelog.EnumerateArray())
|
||||
if (entry.ValueKind == JsonValueKind.Object && Text(entry, "version") == version)
|
||||
return Text(entry, "title");
|
||||
return null;
|
||||
}
|
||||
|
||||
private static JsonElement? Get(JsonElement element, string name)
|
||||
{
|
||||
foreach (var property in element.EnumerateObject())
|
||||
if (string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase))
|
||||
return property.Value;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? Text(JsonElement element, string name) =>
|
||||
Get(element, name) is { ValueKind: JsonValueKind.String } value ? value.GetString() : null;
|
||||
|
||||
private static long Number(JsonElement element, string name) =>
|
||||
Get(element, name) is { ValueKind: JsonValueKind.Number } value && value.TryGetInt64(out var number) ? number : -1;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace GameLauncher.Contracts.Publishing;
|
||||
|
||||
/// <summary>
|
||||
/// Bornes de la publication d'une version depuis le menu d'administration. Les requêtes d'envoi
|
||||
/// (morceaux de fichiers) sont les seules à dépasser 64 Ko : elles ont leur propre limite, côté
|
||||
/// serveur et côté nginx (server/deploy/nginx-publications.conf).
|
||||
/// </summary>
|
||||
public static partial class PublishingLimits
|
||||
{
|
||||
/// <summary>Taille d'un morceau envoyé par le launcher.</summary>
|
||||
public const int ChunkBytes = 4 * 1024 * 1024;
|
||||
|
||||
/// <summary>Taille maximale d'un corps de requête d'envoi accepté par le serveur (morceau ou lot d'objets).</summary>
|
||||
public const long MaxUploadRequestBytes = 8L * 1024 * 1024 + 64 * 1024;
|
||||
|
||||
/// <summary>Objets de cette taille au plus : envoyés par lots ; au-delà, en morceaux.</summary>
|
||||
public const int BatchObjectMaxBytes = 2 * 1024 * 1024;
|
||||
|
||||
public const int MaxObjectsPerBatch = 500;
|
||||
public const int MaxFiles = 10_000;
|
||||
public const long MaxManifestBytes = 64L * 1024 * 1024;
|
||||
public const long MaxObjectBytes = 32L * 1024 * 1024 * 1024;
|
||||
public const long MaxArchiveBytes = 128L * 1024 * 1024 * 1024;
|
||||
|
||||
/// <summary>Publication abandonnée (aucun envoi) au-delà de cette durée : supprimée par le serveur.</summary>
|
||||
public static readonly TimeSpan IdleExpiry = TimeSpan.FromHours(48);
|
||||
|
||||
/// <summary>Publication d'un autre administrateur sans envoi depuis cette durée : peut être remplacée.</summary>
|
||||
public static readonly TimeSpan TakeoverAfter = TimeSpan.FromMinutes(30);
|
||||
|
||||
/// <summary>Type du corps d'un lot d'objets (voir <see cref="ObjectBatch"/>).</summary>
|
||||
public const string ObjectBatchContentType = "application/vnd.gamelauncher.objects";
|
||||
|
||||
public const string BinaryContentType = "application/octet-stream";
|
||||
|
||||
[GeneratedRegex(@"\A[0-9a-f]{32}\z")]
|
||||
private static partial Regex PublicationIdRegex();
|
||||
|
||||
[GeneratedRegex(@"\A[0-9a-fA-F]{64}\z")]
|
||||
private static partial Regex Sha256Regex();
|
||||
|
||||
public static bool IsValidPublicationId(string? id) => id is not null && PublicationIdRegex().IsMatch(id);
|
||||
|
||||
public static bool IsValidSha256(string? value) => value is not null && Sha256Regex().IsMatch(value);
|
||||
}
|
||||
|
||||
/// <summary>Début (ou reprise) de la publication d'une version : le manifest est envoyé ensuite.</summary>
|
||||
public sealed record PublicationStartRequest
|
||||
{
|
||||
public string Version { get; init; } = "";
|
||||
|
||||
/// <summary>Empreinte du manifest.json à publier : même manifest = reprise d'un envoi interrompu.</summary>
|
||||
public string ManifestSha256 { get; init; } = "";
|
||||
|
||||
public long ManifestSize { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>État d'une publication en cours (ce qui reste à envoyer).</summary>
|
||||
public sealed record PublicationState
|
||||
{
|
||||
public string PublicationId { get; init; } = "";
|
||||
public string GameId { get; init; } = "";
|
||||
public string Version { get; init; } = "";
|
||||
|
||||
/// <summary>Version en ligne au début de la publication (vide : aucune).</summary>
|
||||
public string PublishedVersion { get; init; } = "";
|
||||
|
||||
/// <summary>Vrai si un envoi interrompu de ce même manifest est repris.</summary>
|
||||
public bool Resumed { get; init; }
|
||||
|
||||
public long ManifestSize { get; init; }
|
||||
public long ManifestReceived { get; init; }
|
||||
|
||||
/// <summary>Manifest reçu et contrôlé : la liste des objets à envoyer est connue.</summary>
|
||||
public bool ManifestAccepted { get; init; }
|
||||
|
||||
/// <summary>Fichiers (objets) du manifest ; ceux déjà présents sur le serveur ne sont pas renvoyés.</summary>
|
||||
public int ObjectCount { get; init; }
|
||||
public int ObjectsOnServer { get; init; }
|
||||
|
||||
/// <summary>Empreintes des objets restant à envoyer.</summary>
|
||||
public List<string> PendingObjects { get; init; } = new();
|
||||
|
||||
/// <summary>Objets envoyés en partie (empreinte → octets reçus) : l'envoi reprend à cet endroit.</summary>
|
||||
public Dictionary<string, long> PartialObjects { get; init; } = new();
|
||||
|
||||
public string? ArchiveName { get; init; }
|
||||
public long ArchiveSize { get; init; }
|
||||
public long ArchiveReceived { get; init; }
|
||||
public bool ArchiveComplete { get; init; }
|
||||
|
||||
public DateTimeOffset ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Réponse à l'envoi d'un morceau (ou d'un lot d'objets).</summary>
|
||||
public sealed record ChunkResult
|
||||
{
|
||||
/// <summary>Octets reçus pour ce fichier (ou nombre d'objets enregistrés pour un lot).</summary>
|
||||
public long Received { get; init; }
|
||||
|
||||
/// <summary>Fichier complet et vérifié (SHA-256).</summary>
|
||||
public bool Complete { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Version publiée.</summary>
|
||||
public sealed record PublicationResult
|
||||
{
|
||||
public string GameId { get; init; } = "";
|
||||
public string Version { get; init; } = "";
|
||||
public string PreviousVersion { get; init; } = "";
|
||||
public string ManifestSha256 { get; init; } = "";
|
||||
public int ObjectsAdded { get; init; }
|
||||
public bool ArchiveAdded { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lot d'objets dans un seul corps de requête (petits fichiers : une requête pour des centaines de
|
||||
/// fichiers) : pour chaque objet, empreinte SHA-256 (32 octets bruts), taille (8 octets, gros-boutiste)
|
||||
/// puis contenu.
|
||||
/// </summary>
|
||||
public static class ObjectBatch
|
||||
{
|
||||
public const int HeaderBytes = 32 + 8;
|
||||
|
||||
public static byte[] Write(IReadOnlyList<(string Sha256, byte[] Data)> objects)
|
||||
{
|
||||
var total = objects.Sum(o => (long)HeaderBytes + o.Data.Length);
|
||||
var buffer = new byte[total];
|
||||
var position = 0;
|
||||
foreach (var (sha, data) in objects)
|
||||
{
|
||||
Convert.FromHexString(sha).CopyTo(buffer, position);
|
||||
System.Buffers.Binary.BinaryPrimitives.WriteInt64BigEndian(buffer.AsSpan(position + 32, 8), data.Length);
|
||||
data.CopyTo(buffer, position + HeaderBytes);
|
||||
position += HeaderBytes + data.Length;
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/// <summary>Lecture bornée ; <see cref="InvalidDataException"/> si le lot est mal formé.</summary>
|
||||
public static List<(string Sha256, ReadOnlyMemory<byte> Data)> Read(ReadOnlyMemory<byte> body, int maxObjects)
|
||||
{
|
||||
var result = new List<(string, ReadOnlyMemory<byte>)>();
|
||||
var position = 0;
|
||||
while (position < body.Length)
|
||||
{
|
||||
if (result.Count >= maxObjects) throw new InvalidDataException($"Lot trop grand ({maxObjects} objets au plus).");
|
||||
if (body.Length - position < HeaderBytes) throw new InvalidDataException("Lot d'objets tronqué.");
|
||||
var span = body.Span;
|
||||
var sha = Convert.ToHexString(span.Slice(position, 32)).ToLowerInvariant();
|
||||
var length = System.Buffers.Binary.BinaryPrimitives.ReadInt64BigEndian(span.Slice(position + 32, 8));
|
||||
if (length < 0 || length > body.Length - position - HeaderBytes) throw new InvalidDataException("Lot d'objets tronqué.");
|
||||
result.Add((sha, body.Slice(position + HeaderBytes, (int)length)));
|
||||
position += HeaderBytes + (int)length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -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,93 @@
|
||||
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>
|
||||
/// Signature d'un contenu servi aux joueurs (manifest d'un jeu, catalogue) par la clé d'un administrateur
|
||||
/// (phase 7). En-tête distinct : une signature de contenu ne peut pas servir de signature de requête,
|
||||
/// et inversement.
|
||||
/// </summary>
|
||||
public const string ContentHeader = "GameLauncher/Content/v1";
|
||||
|
||||
/// <summary>Signature du trousseau (clés autorisées à signer le contenu) par la clé racine du launcher.</summary>
|
||||
public const string KeyringHeader = "GameLauncher/Keyring/v1";
|
||||
|
||||
/// <param name="kind">« manifest » ou « catalog ».</param>
|
||||
/// <param name="id">Identifiant du jeu (minuscules) ou « catalog ».</param>
|
||||
/// <param name="digest">SHA-256 (hexadécimal minuscule) du contenu canonique.</param>
|
||||
public static byte[] Content(string kind, string id, long signedAt, string keyId, string digest) =>
|
||||
Build(ContentHeader,
|
||||
("kind", kind), ("id", id), ("signed-at", signedAt.ToString(System.Globalization.CultureInfo.InvariantCulture)),
|
||||
("key", keyId), ("digest", digest));
|
||||
|
||||
/// <param name="digest">SHA-256 (hexadécimal minuscule) du trousseau canonique (sans sa signature).</param>
|
||||
public static byte[] Keyring(string rootKeyId, string digest) =>
|
||||
Build(KeyringHeader, ("root", rootKeyId), ("digest", digest));
|
||||
|
||||
/// <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,242 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.Contracts.Signing;
|
||||
|
||||
/// <summary>
|
||||
/// Signature d'un contenu servi aux joueurs (propriété « signature » du manifest.json d'un jeu ou du
|
||||
/// catalog.json), produite par la clé d'un administrateur. Les anciens launchers ignorent cette propriété.
|
||||
/// </summary>
|
||||
public sealed record ContentSignature
|
||||
{
|
||||
/// <summary>Identifiant de la clé qui a signé (16 caractères hexadécimaux).</summary>
|
||||
public string KeyId { get; init; } = "";
|
||||
|
||||
/// <summary>Date de la signature (heure du serveur, à la seconde) : sert aussi à refuser un contenu plus ancien que le dernier vu.</summary>
|
||||
public DateTimeOffset SignedAt { get; init; }
|
||||
|
||||
/// <summary>Signature ECDSA P-256 / SHA-256 (64 octets, IEEE P1363) en Base64.</summary>
|
||||
public string Value { get; init; } = "";
|
||||
}
|
||||
|
||||
/// <summary>Types de contenu signés et autorisations (« scopes ») correspondantes dans le trousseau.</summary>
|
||||
public static class ContentKinds
|
||||
{
|
||||
public const string Manifest = "manifest";
|
||||
public const string Catalog = "catalog";
|
||||
|
||||
/// <summary>Identifiant signé d'un catalogue (il n'y en a qu'un).</summary>
|
||||
public const string CatalogId = "catalog";
|
||||
|
||||
/// <summary>Autorisation du trousseau requise pour signer ce type de contenu.</summary>
|
||||
public static string ScopeOf(string kind) => kind == Manifest ? SigningScopes.Releases : SigningScopes.Catalog;
|
||||
}
|
||||
|
||||
/// <summary>Autorisations d'une clé du trousseau.</summary>
|
||||
public static class SigningScopes
|
||||
{
|
||||
/// <summary>Signer le manifest d'un jeu (ce qui est installé et exécuté) : permission releases.publish.</summary>
|
||||
public const string Releases = "releases";
|
||||
|
||||
/// <summary>Signer le catalogue (noms, liens, images affichés) : permission games.edit.</summary>
|
||||
public const string Catalog = "catalog";
|
||||
|
||||
public static readonly IReadOnlyList<string> All = new[] { Releases, Catalog };
|
||||
|
||||
/// <summary>Autorisations qu'apportent les permissions d'un administrateur.</summary>
|
||||
public static IReadOnlyList<string> FromPermissions(IEnumerable<string> permissions)
|
||||
{
|
||||
var set = permissions.ToHashSet(StringComparer.Ordinal);
|
||||
var scopes = new List<string>();
|
||||
if (set.Contains(AdminPermissions.ReleasesPublish)) scopes.Add(Releases);
|
||||
if (set.Contains(AdminPermissions.GamesEdit)) scopes.Add(Catalog);
|
||||
return scopes;
|
||||
}
|
||||
|
||||
public static string DisplayName(string scope) => scope switch
|
||||
{
|
||||
Releases => "versions du jeu",
|
||||
Catalog => "catalogue",
|
||||
_ => scope
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Partie d'un manifest couverte par la signature : tout ce qui décide de ce qui est installé et exécuté
|
||||
/// (jeu, version, exécutable, arguments, archive, fichiers et leurs empreintes). Les notes de version et la
|
||||
/// date ne le sont pas : elles restent modifiables en ligne (texte borné, jamais exécuté).
|
||||
/// </summary>
|
||||
public sealed record ManifestContent
|
||||
{
|
||||
public int FormatVersion { get; init; } = 1;
|
||||
public string GameId { get; init; } = "";
|
||||
public string Version { get; init; } = "";
|
||||
public string Executable { get; init; } = "";
|
||||
public string? LaunchArguments { get; init; }
|
||||
public ManifestContentArchive Archive { get; init; } = new();
|
||||
public string? ObjectsBaseUrl { get; init; }
|
||||
public List<ManifestContentFile> Files { get; init; } = new();
|
||||
|
||||
/// <summary>Lit un manifest.json (mêmes noms de propriétés que le launcher, casse indifférente).</summary>
|
||||
/// <exception cref="JsonException">JSON illisible.</exception>
|
||||
public static ManifestContent Parse(ReadOnlySpan<byte> json)
|
||||
{
|
||||
if (json.StartsWith(Utf8Bom)) json = json[Utf8Bom.Length..];
|
||||
return JsonSerializer.Deserialize<ManifestContent>(json, ReadOptions) ?? throw new JsonException("manifest vide");
|
||||
}
|
||||
|
||||
private static ReadOnlySpan<byte> Utf8Bom => new byte[] { 0xEF, 0xBB, 0xBF };
|
||||
|
||||
private static readonly JsonSerializerOptions ReadOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
MaxDepth = 32
|
||||
};
|
||||
}
|
||||
|
||||
public sealed record ManifestContentArchive
|
||||
{
|
||||
public string Url { get; init; } = "";
|
||||
public long Size { get; init; }
|
||||
public string Sha256 { get; init; } = "";
|
||||
}
|
||||
|
||||
public sealed record ManifestContentFile
|
||||
{
|
||||
public string Path { get; init; } = "";
|
||||
public long Size { get; init; }
|
||||
public string Sha256 { get; init; } = "";
|
||||
}
|
||||
|
||||
/// <summary>Résultat d'une vérification de signature de contenu.</summary>
|
||||
public sealed record ContentCheck(bool Valid, string Reason, KeyringKey? Key)
|
||||
{
|
||||
public static ContentCheck Fail(string reason) => new(false, reason, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Condensés canoniques et signatures du contenu. Le même code est utilisé par le menu d'administration
|
||||
/// (qui signe), le serveur (qui contrôle avant d'écrire) et le launcher des joueurs (qui vérifie) : chacun
|
||||
/// calcule le condensé à partir de ce qu'il a lui-même lu.
|
||||
/// </summary>
|
||||
public static class ContentSigning
|
||||
{
|
||||
/// <summary>Écart toléré par le serveur entre la date de signature et son horloge.</summary>
|
||||
public static readonly TimeSpan MaxSignedAtSkew = TimeSpan.FromMinutes(10);
|
||||
|
||||
private static readonly JsonSerializerOptions Canonical = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
/// <summary>Condensé du contenu signé d'un manifest (fichiers triés, empreintes en minuscules ; valeurs absentes = vides).</summary>
|
||||
public static string ManifestDigest(ManifestContent manifest)
|
||||
{
|
||||
var archive = manifest.Archive ?? new ManifestContentArchive();
|
||||
var canonical = new ManifestContent
|
||||
{
|
||||
FormatVersion = manifest.FormatVersion,
|
||||
GameId = (manifest.GameId ?? "").ToLowerInvariant(),
|
||||
Version = manifest.Version ?? "",
|
||||
Executable = (manifest.Executable ?? "").Replace('\\', '/'),
|
||||
LaunchArguments = string.IsNullOrEmpty(manifest.LaunchArguments) ? null : manifest.LaunchArguments,
|
||||
Archive = new ManifestContentArchive
|
||||
{
|
||||
Url = archive.Url ?? "",
|
||||
Size = archive.Size,
|
||||
Sha256 = (archive.Sha256 ?? "").ToLowerInvariant()
|
||||
},
|
||||
ObjectsBaseUrl = string.IsNullOrWhiteSpace(manifest.ObjectsBaseUrl) ? "objects/" : manifest.ObjectsBaseUrl,
|
||||
Files = (manifest.Files ?? new List<ManifestContentFile>())
|
||||
.Where(f => f is not null)
|
||||
.Select(f => new ManifestContentFile { Path = f.Path ?? "", Size = f.Size, Sha256 = (f.Sha256 ?? "").ToLowerInvariant() })
|
||||
.OrderBy(f => f.Path, StringComparer.Ordinal)
|
||||
.ThenBy(f => f.Sha256, StringComparer.Ordinal)
|
||||
.ToList()
|
||||
};
|
||||
return Sha256(JsonSerializer.SerializeToUtf8Bytes(canonical, Canonical));
|
||||
}
|
||||
|
||||
/// <summary>Condensé du contenu signé d'un manifest.json (octets bruts).</summary>
|
||||
/// <exception cref="JsonException">JSON illisible.</exception>
|
||||
public static string ManifestDigest(ReadOnlySpan<byte> manifestJson) => ManifestDigest(ManifestContent.Parse(manifestJson));
|
||||
|
||||
/// <summary>
|
||||
/// Condensé du contenu signé d'un catalogue : tout sauf la révision, la date et la signature (posées par le
|
||||
/// serveur) ; jeux triés par identifiant, tels qu'écrits (sans changer la casse : deux identifiants qui ne
|
||||
/// diffèrent que par la casse donneraient deux entrées distinctes, voir <see cref="HasDuplicateGames"/>).
|
||||
/// </summary>
|
||||
public static string CatalogDigest(CatalogDocument catalog)
|
||||
{
|
||||
var games = new SortedDictionary<string, CatalogGame>(StringComparer.Ordinal);
|
||||
foreach (var (id, game) in catalog.Games ?? new Dictionary<string, CatalogGame>())
|
||||
games[id] = game;
|
||||
var canonical = new CanonicalCatalog(catalog.Format, catalog.Version, catalog.Launcher, games);
|
||||
return Sha256(JsonSerializer.SerializeToUtf8Bytes(canonical, Canonical));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Identifiants de jeux en double à la casse près : le launcher n'en garderait qu'un, différent de celui
|
||||
/// couvert par la signature. Un tel catalogue est refusé par la vérification.
|
||||
/// </summary>
|
||||
public static bool HasDuplicateGames(CatalogDocument catalog) =>
|
||||
(catalog.Games?.Keys ?? Enumerable.Empty<string>()).GroupBy(k => k, StringComparer.OrdinalIgnoreCase).Any(g => g.Count() > 1);
|
||||
|
||||
private sealed record CanonicalCatalog(string Format, int Version, CatalogLauncher? Launcher, SortedDictionary<string, CatalogGame> Games);
|
||||
|
||||
/// <summary>Identifiant signé : jeu en minuscules, ou « catalog ».</summary>
|
||||
public static string SignedId(string kind, string id) => kind == ContentKinds.Catalog ? ContentKinds.CatalogId : id.ToLowerInvariant();
|
||||
|
||||
/// <summary>Date de signature ramenée à la seconde (UTC), comme dans le message signé.</summary>
|
||||
public static DateTimeOffset Truncate(DateTimeOffset value) => DateTimeOffset.FromUnixTimeSeconds(value.ToUnixTimeSeconds());
|
||||
|
||||
/// <summary>Message à signer par la clé de l'administrateur.</summary>
|
||||
public static byte[] Message(string kind, string id, DateTimeOffset signedAt, string keyId, string digest) =>
|
||||
SignedMessages.Content(kind, SignedId(kind, id), signedAt.ToUnixTimeSeconds(), keyId, digest);
|
||||
|
||||
/// <summary>Vérifie une signature avec une clé publique donnée (le serveur : la clé de l'administrateur qui envoie).</summary>
|
||||
public static bool VerifyWithKey(string kind, string id, string digest, ContentSignature signature, ReadOnlySpan<byte> publicKey)
|
||||
{
|
||||
if (!AdminKeys.IsValidKeyId(signature.KeyId) || signature.SignedAt == default) return false;
|
||||
if (AdminKeys.DecodeSignature(signature.Value) is not { } value) return false;
|
||||
if (!string.Equals(AdminKeys.ComputeKeyId(publicKey), signature.KeyId, StringComparison.Ordinal)) return false;
|
||||
return AdminKeys.Verify(publicKey, Message(kind, id, signature.SignedAt, signature.KeyId, digest), value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vérifie une signature avec le trousseau (le launcher des joueurs) : clé présente, autorisée pour ce type
|
||||
/// de contenu, signature valide sur le condensé calculé localement.
|
||||
/// </summary>
|
||||
public static ContentCheck Verify(string kind, string id, string digest, ContentSignature? signature, KeyringDocument keyring)
|
||||
{
|
||||
if (signature is null) return ContentCheck.Fail("contenu non signé");
|
||||
if (!AdminKeys.IsValidKeyId(signature.KeyId)) return ContentCheck.Fail("signature mal formée");
|
||||
var key = keyring.Keys.FirstOrDefault(k => string.Equals(k.KeyId, signature.KeyId, StringComparison.Ordinal));
|
||||
if (key is null) return ContentCheck.Fail($"signé par une clé absente du trousseau ({signature.KeyId})");
|
||||
if (key.Scopes is null || !key.Scopes.Contains(ContentKinds.ScopeOf(kind), StringComparer.Ordinal))
|
||||
return ContentCheck.Fail($"la clé {signature.KeyId} ({key.Name}) n'est pas autorisée à signer ce contenu");
|
||||
byte[] publicKey;
|
||||
try
|
||||
{
|
||||
publicKey = Convert.FromBase64String(key.PublicKey);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return ContentCheck.Fail("clé du trousseau illisible");
|
||||
}
|
||||
return VerifyWithKey(kind, id, digest, signature, publicKey)
|
||||
? new ContentCheck(true, $"signé par {key.Name} le {signature.SignedAt:yyyy-MM-dd HH:mm} UTC", key)
|
||||
: ContentCheck.Fail("SIGNATURE INVALIDE (contenu modifié après signature, ou signature d'un autre contenu)");
|
||||
}
|
||||
|
||||
internal static string Sha256(ReadOnlySpan<byte> data) => Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant();
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.Contracts.Signing;
|
||||
|
||||
/// <summary>
|
||||
/// Trousseau (keyring.json) : clés d'administrateurs autorisées à signer le contenu servi aux joueurs, signé
|
||||
/// par la clé RACINE (celle des mises à jour du launcher, dont la clé publique est intégrée à l'exe). La clé
|
||||
/// racine reste hors ligne (LauncherPublisher, « sign-keyring.bat ») : un serveur compromis ne peut ni
|
||||
/// ajouter une clé, ni modifier le trousseau sans que les launchers le refusent.
|
||||
/// </summary>
|
||||
public sealed record KeyringDocument
|
||||
{
|
||||
public const string FormatName = "gamelauncher-keyring";
|
||||
public const int CurrentVersion = 1;
|
||||
|
||||
public string Format { get; init; } = FormatName;
|
||||
public int Version { get; init; } = CurrentVersion;
|
||||
|
||||
/// <summary>Numéro croissant : un launcher refuse un trousseau plus ancien que le dernier qu'il a vu.</summary>
|
||||
public long Sequence { get; init; }
|
||||
|
||||
public DateTimeOffset IssuedAt { get; init; }
|
||||
|
||||
public List<KeyringKey> Keys { get; init; } = new();
|
||||
|
||||
/// <summary>Signature de la clé racine (absente dans une demande de trousseau, avant signature).</summary>
|
||||
public KeyringSignature? Signature { get; init; }
|
||||
}
|
||||
|
||||
public sealed record KeyringKey
|
||||
{
|
||||
public string KeyId { get; init; } = "";
|
||||
|
||||
/// <summary>Clé publique ECDSA P-256 (SPKI, Base64).</summary>
|
||||
public string PublicKey { get; init; } = "";
|
||||
|
||||
/// <summary>Administrateur et appareil (affichage, journal du launcher).</summary>
|
||||
public string Name { get; init; } = "";
|
||||
|
||||
/// <summary>Ce que la clé peut signer (<see cref="SigningScopes"/>).</summary>
|
||||
public List<string> Scopes { get; init; } = new();
|
||||
}
|
||||
|
||||
public sealed record KeyringSignature
|
||||
{
|
||||
/// <summary>Identifiant de la clé racine (16 caractères hexadécimaux, comme pour les mises à jour du launcher).</summary>
|
||||
public string RootKeyId { get; init; } = "";
|
||||
|
||||
public string Value { get; init; } = "";
|
||||
}
|
||||
|
||||
/// <summary>Contrôles, condensé et signature du trousseau (même code partout).</summary>
|
||||
public static class Keyring
|
||||
{
|
||||
/// <summary>Taille maximale de keyring.json.</summary>
|
||||
public const int MaxBytes = 60 * 1024; // envoyé à l'API : sous la limite de 64 Ko des requêtes
|
||||
|
||||
public const int MaxKeys = 100;
|
||||
public const int MaxNameLength = 120;
|
||||
|
||||
private static readonly JsonSerializerOptions Canonical = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
/// <summary>Lit un trousseau (null : JSON vide).</summary>
|
||||
/// <exception cref="JsonException">JSON illisible.</exception>
|
||||
public static KeyringDocument? Parse(ReadOnlySpan<byte> json)
|
||||
{
|
||||
if (json.Length > MaxBytes) throw new JsonException($"trousseau trop volumineux (au plus {MaxBytes / 1024} Ko)");
|
||||
if (json.StartsWith(new byte[] { 0xEF, 0xBB, 0xBF })) json = json[3..];
|
||||
return JsonSerializer.Deserialize<KeyringDocument>(json, ApiJson.Options);
|
||||
}
|
||||
|
||||
public static string Serialize(KeyringDocument keyring) =>
|
||||
JsonSerializer.Serialize(keyring, new JsonSerializerOptions(ApiJson.Options) { WriteIndented = true });
|
||||
|
||||
/// <summary>Condensé du trousseau sans sa signature (clés triées, autorisations triées).</summary>
|
||||
public static string Digest(KeyringDocument keyring)
|
||||
{
|
||||
var canonical = keyring with
|
||||
{
|
||||
Signature = null,
|
||||
IssuedAt = ContentSigning.Truncate(keyring.IssuedAt),
|
||||
Keys = (keyring.Keys ?? new List<KeyringKey>())
|
||||
.Select(k => k with { Scopes = (k.Scopes ?? new List<string>()).Distinct(StringComparer.Ordinal).OrderBy(s => s, StringComparer.Ordinal).ToList() })
|
||||
.OrderBy(k => k.KeyId, StringComparer.Ordinal)
|
||||
.ToList()
|
||||
};
|
||||
return ContentSigning.Sha256(JsonSerializer.SerializeToUtf8Bytes(canonical, Canonical));
|
||||
}
|
||||
|
||||
/// <summary>Contrôle de structure (sans la signature racine). Les erreurs sont ajoutées à <paramref name="errors"/>.</summary>
|
||||
public static bool Validate(KeyringDocument keyring, List<string> errors)
|
||||
{
|
||||
var count = errors.Count;
|
||||
if (keyring.Format != KeyringDocument.FormatName || keyring.Version != KeyringDocument.CurrentVersion)
|
||||
errors.Add($"Format de trousseau non pris en charge ({keyring.Format} v{keyring.Version}).");
|
||||
if (keyring.Sequence < 1) errors.Add("Numéro du trousseau invalide (1 au moins).");
|
||||
if (keyring.IssuedAt == default) errors.Add("Date du trousseau absente.");
|
||||
if (keyring.Keys is null || keyring.Keys.Any(k => k is null || k.Scopes is null || k.PublicKey is null || k.Name is null))
|
||||
{
|
||||
errors.Add("Trousseau incomplet (clés, clé publique, nom ou autorisations absents).");
|
||||
return false;
|
||||
}
|
||||
if (keyring.Keys.Count > MaxKeys) errors.Add($"Trop de clés dans le trousseau (au plus {MaxKeys}).");
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var key in keyring.Keys)
|
||||
{
|
||||
var label = AdminKeys.IsValidKeyId(key.KeyId) ? key.KeyId : "(identifiant invalide)";
|
||||
if (!AdminKeys.IsValidKeyId(key.KeyId)) errors.Add("Clé du trousseau : identifiant invalide.");
|
||||
else if (!seen.Add(key.KeyId)) errors.Add($"Clé {label} en double.");
|
||||
try
|
||||
{
|
||||
var spki = AdminKeys.ParsePublicKey(key.PublicKey);
|
||||
if (!string.Equals(AdminKeys.ComputeKeyId(spki), key.KeyId, StringComparison.Ordinal))
|
||||
errors.Add($"Clé {label} : l'identifiant ne correspond pas à la clé publique.");
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
errors.Add($"Clé {label} : clé publique illisible ou d'un autre type que ECDSA P-256.");
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(key.Name) || key.Name.Length > MaxNameLength || !IsDisplaySafe(key.Name))
|
||||
errors.Add($"Clé {label} : nom absent, trop long ou invalide.");
|
||||
if (key.Scopes.Count == 0 || key.Scopes.Any(s => !SigningScopes.All.Contains(s, StringComparer.Ordinal)))
|
||||
errors.Add($"Clé {label} : autorisations absentes ou inconnues.");
|
||||
}
|
||||
return errors.Count == count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Nom affichable sans tromperie : ni caractères de contrôle, ni caractères de mise en forme invisibles
|
||||
/// (inversion du sens d'écriture…), qui pourraient maquiller un nom dans la console ou les fenêtres.
|
||||
/// </summary>
|
||||
public static bool IsDisplaySafe(string name) =>
|
||||
!name.Any(c => char.IsControl(c) || char.GetUnicodeCategory(c) is System.Globalization.UnicodeCategory.Format
|
||||
or System.Globalization.UnicodeCategory.LineSeparator
|
||||
or System.Globalization.UnicodeCategory.ParagraphSeparator);
|
||||
|
||||
/// <summary>Signe le trousseau avec la clé racine (LauncherPublisher).</summary>
|
||||
public static KeyringDocument Sign(KeyringDocument keyring, ECDsa rootKey)
|
||||
{
|
||||
var rootKeyId = AdminKeys.ComputeKeyId(rootKey.ExportSubjectPublicKeyInfo());
|
||||
var unsigned = keyring with { Signature = null, IssuedAt = ContentSigning.Truncate(keyring.IssuedAt) };
|
||||
var signature = AdminKeys.Sign(rootKey, SignedMessages.Keyring(rootKeyId, Digest(unsigned)));
|
||||
return unsigned with { Signature = new KeyringSignature { RootKeyId = rootKeyId, Value = Convert.ToBase64String(signature) } };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vérifie la signature racine avec les clés publiques intégrées au launcher (celles de update-signing.pub).
|
||||
/// </summary>
|
||||
/// <param name="rootKeys">Identifiant et clé publique SPKI de chaque clé racine de confiance.</param>
|
||||
public static bool VerifyRoot(KeyringDocument keyring, IEnumerable<(string KeyId, byte[] PublicKey)> rootKeys, out string error)
|
||||
{
|
||||
error = "";
|
||||
if (keyring.Signature is not { } signature)
|
||||
{
|
||||
error = "trousseau non signé par la clé racine";
|
||||
return false;
|
||||
}
|
||||
var root = rootKeys.FirstOrDefault(k => string.Equals(k.KeyId, signature.RootKeyId, StringComparison.OrdinalIgnoreCase));
|
||||
if (root.PublicKey is null)
|
||||
{
|
||||
error = $"trousseau signé par une clé racine inconnue de ce launcher ({signature.RootKeyId})";
|
||||
return false;
|
||||
}
|
||||
if (AdminKeys.DecodeSignature(signature.Value) is not { } value ||
|
||||
!AdminKeys.Verify(root.PublicKey, SignedMessages.Keyring(signature.RootKeyId.ToLowerInvariant(), Digest(keyring)), value))
|
||||
{
|
||||
error = "SIGNATURE DU TROUSSEAU INVALIDE";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
namespace GameLauncher.Contracts.Signing;
|
||||
|
||||
/// <summary>Mise en ligne d'une version : signature du manifest par l'administrateur qui publie.</summary>
|
||||
public sealed record PublicationCommitRequest
|
||||
{
|
||||
/// <summary>Signature du contenu du manifest envoyé (<see cref="ContentSigning.ManifestDigest(ManifestContent)"/>).</summary>
|
||||
public ContentSignature? Signature { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Signature du manifest en ligne d'un jeu (avec le condensé calculé par le serveur).</summary>
|
||||
public sealed record ManifestSignatureState
|
||||
{
|
||||
public string GameId { get; init; } = "";
|
||||
|
||||
/// <summary>Version en ligne (vide : aucun manifest publié).</summary>
|
||||
public string Version { get; init; } = "";
|
||||
|
||||
/// <summary>Condensé du contenu signé de la version en ligne (vide : aucun manifest publié).</summary>
|
||||
public string Digest { get; init; } = "";
|
||||
|
||||
public ContentSignature? Signature { get; init; }
|
||||
|
||||
/// <summary>Administrateur et appareil de la clé qui a signé, si le serveur la connaît.</summary>
|
||||
public string? SignerName { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signature de la version en ligne (déjà publiée, par exemple avec WinSCP) à partir du manifest.json du dossier
|
||||
/// de publication : le serveur ne l'accepte que si le contenu en ligne est identique.
|
||||
/// </summary>
|
||||
public sealed record ManifestSignRequest
|
||||
{
|
||||
/// <summary>Condensé calculé par le launcher à partir du manifest.json local.</summary>
|
||||
public string Digest { get; init; } = "";
|
||||
|
||||
public ContentSignature Signature { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Signatures du contenu, pour la page « Signature » (propriétaires).</summary>
|
||||
public sealed record SigningState
|
||||
{
|
||||
/// <summary>Trousseau publié (null : aucun).</summary>
|
||||
public KeyringDocument? Keyring { get; init; }
|
||||
|
||||
/// <summary>Faux : publication du trousseau non configurée (LauncherApi:Keyring:PublicFile, ou catalogue absent).</summary>
|
||||
public bool KeyringPublishingEnabled { get; init; }
|
||||
|
||||
/// <summary>Adresse publique du trousseau (à mettre dans « keyringUrl » de launcher.json).</summary>
|
||||
public string? KeyringPublicUrl { get; init; }
|
||||
|
||||
/// <summary>Vrai : le serveur refuse les publications non signées (LauncherApi:ContentSigning:Require).</summary>
|
||||
public bool RequireSignatures { get; init; }
|
||||
|
||||
public List<ManifestSignatureState> Manifests { get; init; } = new();
|
||||
|
||||
/// <summary>Catalogue publié (null : publication du catalogue non configurée).</summary>
|
||||
public CatalogSignatureState? Catalog { get; init; }
|
||||
}
|
||||
|
||||
public sealed record CatalogSignatureState
|
||||
{
|
||||
public long Revision { get; init; }
|
||||
public string Digest { get; init; } = "";
|
||||
public ContentSignature? Signature { get; init; }
|
||||
public string? SignerName { get; init; }
|
||||
}
|
||||
@@ -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,204 @@
|
||||
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.Trust;
|
||||
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);
|
||||
|
||||
private readonly ContentTrustService? _trust;
|
||||
|
||||
public CatalogService(HttpDownloader http, string dataDirectory, Uri catalogUrl, ContentTrustService? trust = null)
|
||||
: this(http.GetBytesAsync, dataDirectory, catalogUrl, trust) { }
|
||||
|
||||
/// <param name="download">Téléchargement borné (tests : téléchargement simulé).</param>
|
||||
/// <param name="trust">Vérification de la signature (phase 7) : en mode « require », un catalogue refusé est ignoré.</param>
|
||||
public CatalogService(Func<Uri, long, CancellationToken, Task<byte[]>> download, string dataDirectory, Uri catalogUrl,
|
||||
ContentTrustService? trust = null)
|
||||
{
|
||||
_download = download;
|
||||
_catalogUrl = catalogUrl;
|
||||
_trust = trust;
|
||||
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, illisible ou, en mode « require », non signé). Le cache contient le
|
||||
/// catalogue tel que publié (signature comprise) : il est revérifié puis contrôlé à chaque lecture.
|
||||
/// </summary>
|
||||
public CatalogDocument? LoadCached()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(CachePath) || new FileInfo(CachePath).Length > CatalogRules.MaxFileBytes) return null;
|
||||
if (ParseRaw(File.ReadAllBytes(CachePath)) is not { } raw) return null;
|
||||
if (_trust is not null && !_trust.VerifyCachedCatalog(raw)) return null;
|
||||
return CatalogRules.Normalize(raw, _catalogUrl.Host, new List<string>());
|
||||
}
|
||||
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);
|
||||
if (ParseRaw(bytes) is not { } raw) return null;
|
||||
// Signature vérifiée sur le catalogue tel que publié, avant tout contrôle des valeurs.
|
||||
if (_trust is not null && !await _trust.VerifyCatalogAsync(raw, ct).ConfigureAwait(false)) return null;
|
||||
var errors = new List<string>();
|
||||
var catalog = CatalogRules.Normalize(raw, _catalogUrl.Host, errors);
|
||||
foreach (var error in errors.Take(10)) Log.Warn($"Catalogue : valeur ignorée — {error}");
|
||||
|
||||
// Cache : le catalogue tel que publié (la signature reste vérifiable hors ligne).
|
||||
System.IO.Directory.CreateDirectory(Directory);
|
||||
FileUtil.WriteAllTextAtomic(CachePath, JsonSerializer.Serialize(raw, 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>Catalogue tel que publié (null : format non pris en charge).</summary>
|
||||
private static CatalogDocument? ParseRaw(byte[] bytes)
|
||||
{
|
||||
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 raw;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 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,7 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
namespace GameLauncher.Core.Models;
|
||||
@@ -39,9 +41,28 @@ public sealed class GameManifest
|
||||
|
||||
public List<ChangelogEntry> Changelog { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Signature de l'administrateur qui a mis la version en ligne (phase 7), vérifiée avec le trousseau
|
||||
/// (<see cref="Trust.ContentTrustService"/>). Absente des manifests produits par ManifestBuilder.
|
||||
/// </summary>
|
||||
public ContentSignature? Signature { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public long TotalSize => Files.Sum(f => f.Size);
|
||||
|
||||
/// <summary>Partie couverte par la signature (tout ce qui décide de ce qui est installé et exécuté).</summary>
|
||||
public ManifestContent ToSignedContent() => new()
|
||||
{
|
||||
FormatVersion = FormatVersion,
|
||||
GameId = GameId,
|
||||
Version = Version,
|
||||
Executable = Executable,
|
||||
LaunchArguments = LaunchArguments,
|
||||
Archive = new ManifestContentArchive { Url = Archive.Url, Size = Archive.Size, Sha256 = Archive.Sha256 },
|
||||
ObjectsBaseUrl = ObjectsBaseUrl,
|
||||
Files = Files.Select(f => new ManifestContentFile { Path = f.Path, Size = f.Size, Sha256 = f.Sha256 }).ToList()
|
||||
};
|
||||
|
||||
/// <summary>URL d'où le manifest a été téléchargé (sert à résoudre les URL relatives).</summary>
|
||||
[JsonIgnore]
|
||||
public Uri? SourceUri { get; set; }
|
||||
|
||||
@@ -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", "keyringUrl", "contentSigning", "keyringMinSequence" };
|
||||
|
||||
/// <summary>Nom par défaut du dossier des données du launcher (%AppData%\<Publisher>\GameLauncher).</summary>
|
||||
public const string DefaultDataFolderName = "GameLauncher";
|
||||
|
||||
private const string GamesFolderName = "Games";
|
||||
private const string GameSettingsFileName = "settings.json";
|
||||
|
||||
private string? _dataDirectory;
|
||||
private readonly Dictionary<string, string> _gameSettingsPaths = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private LauncherCatalog(string launcherId, string launcherName, string publisher, string? defaultGameId,
|
||||
IReadOnlyList<LauncherConfig> games, bool isMultiGameFormat)
|
||||
@@ -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,197 @@ 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>
|
||||
/// Adresse du trousseau (keyring.json : clés autorisées à signer le contenu, signé par la clé racine du
|
||||
/// launcher), ex. « https://launcher.lanisys.fr/catalog/keyring.json ». Facultatif. Comme <see cref="ApiUrl"/>,
|
||||
/// le launcher publié ne la lit que dans le launcher.json intégré à l'exe.
|
||||
/// </summary>
|
||||
public string? KeyringUrl { get; private set; }
|
||||
|
||||
/// <summary>Raison pour laquelle « keyringUrl » a été ignorée (valeur invalide), sinon null.</summary>
|
||||
public string? KeyringUrlError { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// « contentSigning » : vérification des signatures du manifest et du catalogue. « warn » (défaut si
|
||||
/// keyringUrl est renseignée) : un contenu non signé ou mal signé est signalé dans le journal mais utilisé ;
|
||||
/// « require » : il est refusé (aucune installation ni mise à jour, catalogue ignoré) ; « off » : rien n'est vérifié.
|
||||
/// Comme keyringUrl, lu uniquement dans le launcher.json intégré à l'exe.
|
||||
/// </summary>
|
||||
public Trust.ContentSigningMode ContentSigning { get; private set; }
|
||||
|
||||
/// <summary>Raison pour laquelle « contentSigning » a été ignorée ou est incohérente, sinon null.</summary>
|
||||
public string? ContentSigningError { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// « keyringMinSequence » : numéro minimal du trousseau accepté (0 par défaut). Un trousseau plus ancien, même
|
||||
/// signé par la clé racine, est refusé : à relever dans le launcher publié après le retrait d'une clé compromise,
|
||||
/// pour qu'une installation neuve n'accepte pas un ancien trousseau qui la contenait encore. Intégré à l'exe.
|
||||
/// </summary>
|
||||
public long KeyringMinSequence { 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%\<Publisher></summary>
|
||||
public string PublisherDirectory =>
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), Publisher);
|
||||
|
||||
/// <summary>
|
||||
/// Dossier des données du launcher : %AppData%\<Publisher>\GameLauncher (paramètres du
|
||||
/// launcher, mise à jour du launcher, journaux, dernier jeu affiché ; paramètres de chaque jeu
|
||||
/// dans Games\<GameId>). Voir <see cref="PrepareStorage"/>.
|
||||
/// </summary>
|
||||
public string DataDirectory => _dataDirectory ?? Path.Combine(PublisherDirectory, DataFolderName);
|
||||
|
||||
/// <summary>
|
||||
/// Ancien dossier des données (launcher 1.2.1 et antérieurs) : %AppData%\<Publisher>\<LauncherId>
|
||||
/// (ex. EZRLauncher avec un seul jeu).
|
||||
/// </summary>
|
||||
public string LegacyDataDirectory => Path.Combine(PublisherDirectory, LauncherId);
|
||||
|
||||
/// <summary>Vrai si l'ancien dossier est encore utilisé pour cette session (déplacement différé ou impossible).</summary>
|
||||
public bool UsesLegacyStorage { get; private set; }
|
||||
|
||||
/// <summary>Paramètres propres à un jeu : <DataDirectory>\Games\<GameId>\settings.json.</summary>
|
||||
public string GetGameSettingsPath(LauncherConfig game) =>
|
||||
_gameSettingsPaths.TryGetValue(game.GameId, out var path)
|
||||
? path
|
||||
: Path.Combine(DataDirectory, GamesFolderName, game.GameId, GameSettingsFileName);
|
||||
|
||||
/// <summary>
|
||||
/// Détermine les dossiers à utiliser et, si <paramref name="allowMigration"/>, déplace les données
|
||||
/// des versions précédentes :
|
||||
/// • %AppData%\<Publisher>\<LauncherId> (ex. EZRLauncher) → %AppData%\<Publisher>\GameLauncher
|
||||
/// (renommage du dossier, instantané) ;
|
||||
/// • paramètres de chaque jeu (settings.json à la racine de l'ancien dossier, ou dans
|
||||
/// %AppData%\<Publisher>\<GameId>Launcher) → GameLauncher\Games\<GameId>\settings.json.
|
||||
/// Sans déplacement (autre instance ouverte, démarrage après une mise à jour du launcher) ou en cas
|
||||
/// d'échec, l'ancien dossier reste utilisé pour cette session : rien n'est perdu, le déplacement est
|
||||
/// retenté au démarrage suivant.
|
||||
/// </summary>
|
||||
/// <returns>Messages à écrire dans le journal (le journal n'est pas encore ouvert) ; « ! » = avertissement.</returns>
|
||||
public IReadOnlyList<string> PrepareStorage(bool allowMigration)
|
||||
{
|
||||
var notes = new List<string>();
|
||||
var target = Path.Combine(PublisherDirectory, DataFolderName);
|
||||
var legacy = LegacyDataDirectory;
|
||||
_dataDirectory = target;
|
||||
UsesLegacyStorage = false;
|
||||
_gameSettingsPaths.Clear();
|
||||
|
||||
if (!SamePath(target, legacy) && !Directory.Exists(target) && Directory.Exists(legacy))
|
||||
{
|
||||
if (!allowMigration)
|
||||
{
|
||||
UseLegacy();
|
||||
notes.Add($"Dossier des données : {legacy} utilisé pour cette session (déplacement vers {target} au prochain démarrage).");
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Move(legacy, target); // même dossier parent : simple renommage
|
||||
notes.Add($"Données du launcher déplacées : {legacy} → {target}");
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
UseLegacy();
|
||||
notes.Add($"! Déplacement de {legacy} vers {target} impossible ({ex.Message}) : l'ancien dossier reste utilisé.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var game in Games)
|
||||
{
|
||||
var legacyGameDirectory = Path.Combine(PublisherDirectory, game.GameId + "Launcher");
|
||||
if (UsesLegacyStorage)
|
||||
{
|
||||
_gameSettingsPaths[game.GameId] = Path.Combine(legacyGameDirectory, GameSettingsFileName);
|
||||
continue;
|
||||
}
|
||||
|
||||
var path = Path.Combine(target, GamesFolderName, game.GameId, GameSettingsFileName);
|
||||
_gameSettingsPaths[game.GameId] = path;
|
||||
if (File.Exists(path)) continue;
|
||||
|
||||
// Ancien emplacement : à la racine du dossier déplacé (un seul jeu : même dossier que le
|
||||
// launcher) ou dans le dossier propre au jeu (plusieurs jeux).
|
||||
var candidates = new List<string>();
|
||||
if (SamePath(legacyGameDirectory, legacy)) candidates.Add(Path.Combine(target, GameSettingsFileName));
|
||||
candidates.Add(Path.Combine(legacyGameDirectory, GameSettingsFileName));
|
||||
var source = candidates.FirstOrDefault(File.Exists);
|
||||
if (source is null) continue;
|
||||
|
||||
if (!allowMigration)
|
||||
{
|
||||
_gameSettingsPaths[game.GameId] = source;
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
File.Move(source, path);
|
||||
notes.Add($"[{game.GameId}] Paramètres du jeu déplacés : {source} → {path}");
|
||||
|
||||
// Ancien dossier propre au jeu, désormais vide : supprimé.
|
||||
var sourceDirectory = Path.GetDirectoryName(source)!;
|
||||
if (!SamePath(sourceDirectory, target) && Directory.Exists(sourceDirectory) &&
|
||||
!Directory.EnumerateFileSystemEntries(sourceDirectory).Any())
|
||||
Directory.Delete(sourceDirectory);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_gameSettingsPaths[game.GameId] = source;
|
||||
notes.Add($"! [{game.GameId}] Déplacement des paramètres du jeu impossible ({ex.Message}) : {source} reste utilisé.");
|
||||
}
|
||||
}
|
||||
return notes;
|
||||
|
||||
void UseLegacy()
|
||||
{
|
||||
_dataDirectory = legacy;
|
||||
UsesLegacyStorage = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool SamePath(string a, string b) =>
|
||||
string.Equals(Path.GetFullPath(a).TrimEnd(Path.DirectorySeparatorChar),
|
||||
Path.GetFullPath(b).TrimEnd(Path.DirectorySeparatorChar), StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Nom du mutex garantissant une seule instance du launcher.</summary>
|
||||
public string SingleInstanceName =>
|
||||
@@ -66,10 +274,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,8 +312,13 @@ 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)
|
||||
}.WithContentSigning(root);
|
||||
}
|
||||
|
||||
if (gamesNode is not JsonArray array || array.Count == 0)
|
||||
@@ -153,8 +367,53 @@ 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)
|
||||
}.WithContentSigning(root);
|
||||
}
|
||||
|
||||
/// <summary>« keyringUrl », « contentSigning » et « keyringMinSequence » (phase 7).</summary>
|
||||
private LauncherCatalog WithContentSigning(JsonObject root)
|
||||
{
|
||||
KeyringUrl = ReadTrustedUrl(root, "keyringUrl", "https://launcher.exemple.fr/catalog/keyring.json", out var keyringError);
|
||||
KeyringUrlError = keyringError;
|
||||
var text = Get(root, "contentSigning") is JsonValue value && value.TryGetValue<string>(out var s) ? s.Trim() : null;
|
||||
var declared = Get(root, "contentSigning") is not null;
|
||||
(ContentSigning, ContentSigningError) = text?.ToLowerInvariant() switch
|
||||
{
|
||||
"off" => (Trust.ContentSigningMode.Off, null),
|
||||
"warn" => (Trust.ContentSigningMode.Warn, null),
|
||||
"require" => (Trust.ContentSigningMode.Require, null),
|
||||
_ when !declared => (KeyringUrl is null ? Trust.ContentSigningMode.Off : Trust.ContentSigningMode.Warn, null),
|
||||
// Valeur invalide : prudence (vérification sans blocage), signalée dans le journal.
|
||||
_ => (Trust.ContentSigningMode.Warn,
|
||||
"launcher.json : contentSigning ignorée (valeurs possibles : \"off\", \"warn\", \"require\") : vérification sans blocage (« warn »).")
|
||||
};
|
||||
if (Get(root, "keyringMinSequence") is { } minNode)
|
||||
{
|
||||
if (minNode is JsonValue minValue && minValue.TryGetValue<long>(out var min) && min >= 0)
|
||||
KeyringMinSequence = min;
|
||||
else
|
||||
ContentSigningError ??= "launcher.json : keyringMinSequence ignorée (nombre entier positif attendu).";
|
||||
}
|
||||
if (ContentSigning == Trust.ContentSigningMode.Require && KeyringUrl is null)
|
||||
ContentSigningError = "launcher.json : contentSigning « require » sans keyringUrl valide : aucun manifest ni catalogue ne pourra être " +
|
||||
"vérifié, donc aucune installation ni mise à jour ne sera possible.";
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <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)
|
||||
@@ -167,6 +426,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%\<Publisher>\<LauncherId>\launcher-settings.json.
|
||||
/// %AppData%\<Publisher>\GameLauncher\launcher-settings.json (voir LauncherCatalog.DataDirectory).
|
||||
/// (La mise à jour automatique du launcher lui-même est dans self-update.json.)
|
||||
/// </summary>
|
||||
public sealed class LauncherPreferences
|
||||
@@ -16,6 +16,18 @@ public sealed class LauncherPreferences
|
||||
/// <summary>Fermer le launcher au lancement du jeu.</summary>
|
||||
public bool CloseOnLaunch { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Avec <see cref="MinimizeOnLaunch"/> : pendant la partie, le launcher est réduit dans la zone de
|
||||
/// notification (icône près de l'horloge) au lieu de la barre des tâches.
|
||||
/// </summary>
|
||||
public bool MinimizeToTray { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// La croix de la fenêtre réduit le launcher dans la zone de notification au lieu de le quitter
|
||||
/// (il se quitte alors par le menu de son icône › Quitter).
|
||||
/// </summary>
|
||||
public bool CloseToTray { get; set; }
|
||||
|
||||
public static string GetPath(string dataDirectory) => Path.Combine(dataDirectory, "launcher-settings.json");
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -5,7 +5,8 @@ namespace GameLauncher.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Préférences propres à UN jeu (menu « … » › Paramètres du jeu) :
|
||||
/// %AppData%\{Publisher}\{GameId}Launcher\settings.json.
|
||||
/// %AppData%\{Publisher}\GameLauncher\Games\{GameId}\settings.json (voir LauncherCatalog.GetGameSettingsPath ;
|
||||
/// launcher 1.2.1 et antérieurs : %AppData%\{Publisher}\{GameId}Launcher\settings.json).
|
||||
/// Les préférences communes à tous les jeux sont dans <see cref="LauncherPreferences"/>.
|
||||
/// </summary>
|
||||
public sealed class LauncherSettings
|
||||
@@ -26,13 +27,6 @@ public sealed class LauncherSettings
|
||||
|
||||
public LauncherSettings Clone() => (LauncherSettings)MemberwiseClone();
|
||||
|
||||
public static string GetDefaultPath(LauncherConfig config) =>
|
||||
Path.Combine(GetDataDirectory(config), "settings.json");
|
||||
|
||||
/// <summary>%AppData%\{Publisher}\{GameId}Launcher</summary>
|
||||
public static string GetDataDirectory(LauncherConfig config) =>
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
config.Publisher, config.GameId + "Launcher");
|
||||
|
||||
public static LauncherSettings Load(string path)
|
||||
{
|
||||
|
||||
@@ -24,4 +24,14 @@ public sealed record VerifyResult(
|
||||
}
|
||||
|
||||
/// <summary>Résultat d'une désinstallation.</summary>
|
||||
public sealed record UninstallResult(int DeletedFiles, int RemainingFiles, bool FolderRemoved);
|
||||
/// <param name="DeletedFiles">Fichiers supprimés.</param>
|
||||
/// <param name="RemainingFiles">Fichiers personnels conservés (hors fichiers bloqués et fichiers du launcher).</param>
|
||||
/// <param name="FolderRemoved">Le dossier du jeu a disparu.</param>
|
||||
public sealed record UninstallResult(int DeletedFiles, int RemainingFiles, bool FolderRemoved)
|
||||
{
|
||||
/// <summary>Fichiers qui n'ont pas pu être supprimés (fichier ouvert, antivirus…).</summary>
|
||||
public IReadOnlyList<string> LockedFiles { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>Fichiers du launcher conservés (launcher placé dans le dossier du jeu).</summary>
|
||||
public int LauncherFilesKept { get; init; }
|
||||
}
|
||||
@@ -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%\<Publisher>\<LauncherId>\self-update.json
|
||||
/// %AppData%\<Publisher>\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;
|
||||
|
||||
|
||||
@@ -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,5 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.Trust;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
namespace GameLauncher.Core.Services;
|
||||
@@ -8,11 +10,16 @@ public sealed class ManifestClient
|
||||
{
|
||||
private readonly HttpDownloader _http;
|
||||
private readonly Uri _manifestUri;
|
||||
private readonly ContentTrustService? _trust;
|
||||
private readonly string? _gameId;
|
||||
|
||||
public ManifestClient(HttpDownloader http, Uri manifestUri)
|
||||
/// <param name="trust">Vérification des signatures (phase 7), avec l'identifiant du jeu de launcher.json.</param>
|
||||
public ManifestClient(HttpDownloader http, Uri manifestUri, ContentTrustService? trust = null, string? gameId = null)
|
||||
{
|
||||
_http = http;
|
||||
_manifestUri = manifestUri;
|
||||
_trust = trust;
|
||||
_gameId = gameId;
|
||||
}
|
||||
|
||||
public async Task<GameManifest> FetchAsync(CancellationToken ct = default)
|
||||
@@ -31,6 +38,8 @@ public sealed class ManifestClient
|
||||
|
||||
manifest.SourceUri = _manifestUri;
|
||||
manifest.Validate();
|
||||
// Signature (phase 7) : refus en mode « require » (ContentSecurityException), sinon journal seulement.
|
||||
if (_trust is not null && _gameId is not null) await _trust.VerifyManifestAsync(manifest, _gameId, ct).ConfigureAwait(false);
|
||||
// Notes de version bornées et nettoyées : elles sont affichées telles quelles dans l'interface.
|
||||
manifest.Changelog = ChangelogLimits.Normalize(manifest.Changelog);
|
||||
Log.Info($"Manifest récupéré : version {manifest.Version}, {manifest.Files.Count} fichiers, {Format.Bytes(manifest.TotalSize)}.");
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
namespace GameLauncher.Core.Trust;
|
||||
|
||||
/// <summary>Vérification des signatures du contenu (« contentSigning » de launcher.json).</summary>
|
||||
public enum ContentSigningMode
|
||||
{
|
||||
/// <summary>Rien n'est vérifié (comportement antérieur à la phase 7).</summary>
|
||||
Off,
|
||||
|
||||
/// <summary>Signatures vérifiées ; un problème est inscrit au journal, le contenu est utilisé quand même (mise en place).</summary>
|
||||
Warn,
|
||||
|
||||
/// <summary>Un contenu non signé, mal signé ou plus ancien que le dernier vu est refusé.</summary>
|
||||
Require
|
||||
}
|
||||
|
||||
/// <summary>Contenu refusé (mode « require ») : signature absente ou invalide, trousseau indisponible, retour en arrière.</summary>
|
||||
public sealed class ContentSecurityException : Exception
|
||||
{
|
||||
public ContentSecurityException(string message) : base(message) { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confiance dans le contenu servi par le serveur (phase 7) :
|
||||
/// • trousseau (keyring.json) téléchargé puis vérifié avec la ou les clés RACINE intégrées à l'exe (celles des
|
||||
/// mises à jour du launcher), conservé en cache ; un trousseau plus ancien que le dernier vu est refusé ;
|
||||
/// • manifest et catalogue : signature d'une clé du trousseau autorisée pour ce contenu, sur le condensé calculé
|
||||
/// ici à partir de ce qui a été téléchargé ; un contenu signé plus tôt que le dernier accepté est refusé (un
|
||||
/// serveur compromis ne peut pas resservir une ancienne version vulnérable) ;
|
||||
/// • mode « warn » : problèmes inscrits au journal sans blocage ; « require » : contenu refusé.
|
||||
/// État conservé dans le dossier des données du launcher (trust\content-trust.json).
|
||||
/// </summary>
|
||||
public sealed class ContentTrustService
|
||||
{
|
||||
private static readonly TimeSpan FetchTimeout = TimeSpan.FromSeconds(20);
|
||||
private static readonly TimeSpan RetryDelay = TimeSpan.FromMinutes(1);
|
||||
private static readonly TimeSpan UnknownKeyRetryDelay = TimeSpan.FromMinutes(5);
|
||||
private static readonly TimeSpan MaxFutureSignedAt = TimeSpan.FromDays(1);
|
||||
|
||||
private readonly Func<Uri, long, CancellationToken, Task<byte[]>> _download;
|
||||
private readonly Uri? _keyringUrl;
|
||||
private readonly IReadOnlyList<(string KeyId, byte[] PublicKey)> _rootKeys;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly object _lock = new();
|
||||
private Task<KeyringDocument?>? _keyringTask;
|
||||
private bool _keyringFetched;
|
||||
private DateTimeOffset _lastAttempt;
|
||||
private TrustState? _state;
|
||||
|
||||
/// <param name="rootKeys">Clés racine de confiance (update-signing.pub intégré à l'exe).</param>
|
||||
/// <param name="minKeyringSequence">
|
||||
/// Numéro minimal du trousseau (« keyringMinSequence » de launcher.json) : un trousseau plus ancien, même signé par
|
||||
/// la racine, est refusé. À relever après le retrait d'une clé compromise, pour les installations neuves.
|
||||
/// </param>
|
||||
/// <param name="time">Horloge (tests).</param>
|
||||
public ContentTrustService(Func<Uri, long, CancellationToken, Task<byte[]>> download, string dataDirectory, Uri? keyringUrl,
|
||||
IReadOnlyList<(string KeyId, byte[] PublicKey)> rootKeys, ContentSigningMode mode, long minKeyringSequence = 0,
|
||||
TimeProvider? time = null)
|
||||
{
|
||||
_download = download;
|
||||
_time = time ?? TimeProvider.System;
|
||||
_keyringUrl = keyringUrl;
|
||||
_rootKeys = rootKeys;
|
||||
Mode = mode;
|
||||
MinKeyringSequence = Math.Max(0, minKeyringSequence);
|
||||
StatePath = Path.Combine(dataDirectory, "trust", "content-trust.json");
|
||||
}
|
||||
|
||||
public ContentSigningMode Mode { get; }
|
||||
public long MinKeyringSequence { get; }
|
||||
public string StatePath { get; }
|
||||
|
||||
// ------------------------------------------------------------------ Trousseau
|
||||
|
||||
/// <summary>
|
||||
/// Trousseau à utiliser : celui du serveur s'il est valide et pas plus ancien que le dernier vu, sinon le dernier
|
||||
/// vu (cache), sinon null. Téléchargé une fois par démarrage (nouvel essai après un échec réseau, ou après quelques
|
||||
/// minutes quand un contenu est signé par une clé encore inconnue : clé ajoutée pendant que le launcher tourne).
|
||||
/// </summary>
|
||||
public Task<KeyringDocument?> GetKeyringAsync(CancellationToken ct = default) => GetKeyringAsync(false, ct);
|
||||
|
||||
private async Task<KeyringDocument?> GetKeyringAsync(bool unknownKey, CancellationToken ct)
|
||||
{
|
||||
Task<KeyringDocument?> task;
|
||||
lock (_lock)
|
||||
{
|
||||
var elapsed = _time.GetUtcNow() - _lastAttempt;
|
||||
if (_keyringTask is null
|
||||
|| (_keyringTask.IsCompleted && !_keyringFetched && elapsed > RetryDelay)
|
||||
|| (_keyringTask.IsCompleted && unknownKey && elapsed > UnknownKeyRetryDelay))
|
||||
{
|
||||
_lastAttempt = _time.GetUtcNow();
|
||||
_keyringTask = FetchKeyringAsync();
|
||||
}
|
||||
task = _keyringTask;
|
||||
}
|
||||
return await task.WaitAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Dernier trousseau vérifié en cache (sans réseau).</summary>
|
||||
public KeyringDocument? CachedKeyring => LoadState().Keyring;
|
||||
|
||||
private async Task<KeyringDocument?> FetchKeyringAsync()
|
||||
{
|
||||
var cached = LoadState().Keyring;
|
||||
if (_keyringUrl is null || Mode == ContentSigningMode.Off) return cached;
|
||||
try
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(FetchTimeout);
|
||||
var bytes = await _download(_keyringUrl, Keyring.MaxBytes, timeout.Token).ConfigureAwait(false);
|
||||
var keyring = Keyring.Parse(bytes) ?? throw new InvalidDataException("trousseau vide");
|
||||
var errors = new List<string>();
|
||||
if (!Keyring.Validate(keyring, errors)) throw new InvalidDataException(errors[0]);
|
||||
if (!Keyring.VerifyRoot(keyring, _rootKeys, out var error)) throw new InvalidDataException(error);
|
||||
if (keyring.Sequence < MinKeyringSequence)
|
||||
throw new InvalidDataException($"trousseau n° {keyring.Sequence} plus ancien que le minimum n° {MinKeyringSequence} de ce launcher");
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_keyringFetched = true;
|
||||
if (cached is not null)
|
||||
{
|
||||
if (keyring.Sequence < cached.Sequence)
|
||||
{
|
||||
Log.Error($"Trousseau n° {keyring.Sequence} refusé : plus ancien que le n° {cached.Sequence} déjà vu (retour en arrière).");
|
||||
return cached;
|
||||
}
|
||||
if (keyring.Sequence == cached.Sequence && Keyring.Digest(keyring) != Keyring.Digest(cached))
|
||||
{
|
||||
Log.Error($"Trousseau n° {keyring.Sequence} refusé : différent de celui déjà vu sous le même numéro.");
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
var state = LoadState();
|
||||
if (cached is null || keyring.Sequence > cached.Sequence)
|
||||
{
|
||||
state.Keyring = keyring;
|
||||
SaveState(state);
|
||||
Log.Info($"Trousseau n° {keyring.Sequence} vérifié ({keyring.Keys.Count} clé(s)).");
|
||||
}
|
||||
return keyring;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidDataException or JsonException or NotSupportedException or FormatException)
|
||||
{
|
||||
lock (_lock) _keyringFetched = true; // contenu refusé : pas de nouvel essai pendant ce démarrage
|
||||
Log.Error($"Trousseau du serveur refusé : {ex.Message}" + (cached is null ? "" : $" (le n° {cached.Sequence} déjà vu reste utilisé)."));
|
||||
return cached;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Réseau, délai dépassé, réponse trop grande… : jamais d'exception vers l'appelant, nouvel essai plus tard.
|
||||
Log.Warn($"Trousseau injoignable : {ex.Message}" + (cached is null ? "" : $" (le n° {cached.Sequence} déjà vu est utilisé)."));
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Contenu
|
||||
|
||||
/// <summary>Vérifie le manifest d'un jeu (lève <see cref="ContentSecurityException"/> en mode « require »).</summary>
|
||||
public Task VerifyManifestAsync(GameManifest manifest, string gameId, CancellationToken ct = default)
|
||||
{
|
||||
if (Mode == ContentSigningMode.Off) return Task.CompletedTask;
|
||||
return VerifyAsync(ContentKinds.Manifest, gameId, ContentSigning.ManifestDigest(manifest.ToSignedContent()), manifest.Signature,
|
||||
$"Manifest de {gameId} (version {manifest.Version})", ct);
|
||||
}
|
||||
|
||||
/// <summary>Vérifie le catalogue tel que téléchargé (avant tout contrôle des valeurs). Faux : à ignorer.</summary>
|
||||
public async Task<bool> VerifyCatalogAsync(CatalogDocument catalog, CancellationToken ct = default)
|
||||
{
|
||||
if (Mode == ContentSigningMode.Off) return true;
|
||||
var what = $"Catalogue (révision {catalog.Revision})";
|
||||
try
|
||||
{
|
||||
if (ContentSigning.HasDuplicateGames(catalog))
|
||||
return Report(what, "jeux en double (identifiants ne différant que par la casse)");
|
||||
await VerifyAsync(ContentKinds.Catalog, ContentKinds.CatalogId, ContentSigning.CatalogDigest(catalog), catalog.Signature, what, ct)
|
||||
.ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
catch (ContentSecurityException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Revérifie le catalogue en cache au démarrage, sans réseau, avec le dernier trousseau vérifié : en mode
|
||||
/// « require », un catalogue en cache non signé (écrit avant l'activation, ou modifié sur le disque) est ignoré.
|
||||
/// L'antériorité n'est pas contrôlée ici (le cache est ce qui a déjà été accepté).
|
||||
/// </summary>
|
||||
public bool VerifyCachedCatalog(CatalogDocument catalog)
|
||||
{
|
||||
if (Mode == ContentSigningMode.Off) return true;
|
||||
var what = $"Catalogue en cache (révision {catalog.Revision})";
|
||||
if (ContentSigning.HasDuplicateGames(catalog)) return Report(what, "jeux en double (identifiants ne différant que par la casse)");
|
||||
var keyring = LoadState().Keyring;
|
||||
if (keyring is null) return Report(what, "aucun trousseau vérifié en cache");
|
||||
var check = ContentSigning.Verify(ContentKinds.Catalog, ContentKinds.CatalogId, ContentSigning.CatalogDigest(catalog), catalog.Signature, keyring);
|
||||
return check.Valid || Report(what, check.Reason);
|
||||
}
|
||||
|
||||
/// <summary>Problème : refus en mode « require » (faux), avertissement en mode « warn » (vrai).</summary>
|
||||
private bool Report(string what, string problem)
|
||||
{
|
||||
if (Mode == ContentSigningMode.Require)
|
||||
{
|
||||
Log.Error($"{what} : {problem}. Contenu refusé (contentSigning « require »).");
|
||||
return false;
|
||||
}
|
||||
Log.Warn($"{what} : {problem}. Contenu utilisé quand même (contentSigning « warn »).");
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task VerifyAsync(string kind, string id, string digest, ContentSignature? signature, string what, CancellationToken ct)
|
||||
{
|
||||
var keyring = await GetKeyringAsync(false, ct).ConfigureAwait(false);
|
||||
// Clé inconnue (ajoutée depuis le démarrage) : nouveau téléchargement du trousseau, limité dans le temps.
|
||||
if (keyring is not null && signature?.KeyId is { } keyId && !keyring.Keys.Any(k => string.Equals(k.KeyId, keyId, StringComparison.Ordinal)))
|
||||
keyring = await GetKeyringAsync(true, ct).ConfigureAwait(false);
|
||||
|
||||
string problem;
|
||||
if (keyring is null)
|
||||
{
|
||||
problem = _keyringUrl is null ? "aucun trousseau configuré (keyringUrl)" : "trousseau indisponible (jamais téléchargé)";
|
||||
}
|
||||
else
|
||||
{
|
||||
var check = ContentSigning.Verify(kind, id, digest, signature, keyring);
|
||||
if (!check.Valid)
|
||||
{
|
||||
problem = check.Reason;
|
||||
}
|
||||
else
|
||||
{
|
||||
var slot = $"{kind}:{ContentSigning.SignedId(kind, id)}";
|
||||
lock (_lock)
|
||||
{
|
||||
var state = LoadState();
|
||||
if (state.LastSigned.TryGetValue(slot, out var last) && signature!.SignedAt < last)
|
||||
{
|
||||
problem = $"signé le {signature.SignedAt:yyyy-MM-dd HH:mm} UTC, plus ancien que le contenu déjà vu " +
|
||||
$"(signé le {last:yyyy-MM-dd HH:mm} UTC) : retour en arrière refusé";
|
||||
}
|
||||
else
|
||||
{
|
||||
// Date enregistrée plafonnée : une signature datée loin dans le futur (horloge déréglée) ne doit
|
||||
// pas faire refuser toutes les publications suivantes.
|
||||
var ceiling = ContentSigning.Truncate(_time.GetUtcNow() + MaxFutureSignedAt);
|
||||
var recorded = signature!.SignedAt > ceiling ? ceiling : signature.SignedAt;
|
||||
if (recorded > last)
|
||||
{
|
||||
state.LastSigned[slot] = recorded;
|
||||
SaveState(state);
|
||||
}
|
||||
Log.Info($"{what} : {check.Reason}.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!Report(what, problem)) throw new ContentSecurityException($"{what} : {problem}.");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ État
|
||||
|
||||
private sealed class TrustState
|
||||
{
|
||||
public KeyringDocument? Keyring { get; set; }
|
||||
public Dictionary<string, DateTimeOffset> LastSigned { get; set; } = new(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>État en cache (le trousseau est revérifié à la lecture : un fichier modifié à la main est ignoré).</summary>
|
||||
private TrustState LoadState()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_state is not null) return _state;
|
||||
var state = new TrustState();
|
||||
try
|
||||
{
|
||||
if (File.Exists(StatePath) && new FileInfo(StatePath).Length <= Keyring.MaxBytes * 2)
|
||||
{
|
||||
var read = JsonSerializer.Deserialize<TrustState>(File.ReadAllText(StatePath), ApiJson.Options);
|
||||
if (read is not null)
|
||||
{
|
||||
state.LastSigned = new Dictionary<string, DateTimeOffset>(read.LastSigned ?? new(), StringComparer.Ordinal);
|
||||
var errors = new List<string>();
|
||||
if (read.Keyring is { } keyring && Keyring.Validate(keyring, errors) && Keyring.VerifyRoot(keyring, _rootKeys, out _)
|
||||
&& keyring.Sequence >= MinKeyringSequence)
|
||||
state.Keyring = keyring;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
|
||||
{
|
||||
Log.Warn($"État de vérification des signatures illisible ({ex.Message}) : recréé.");
|
||||
}
|
||||
return _state = state;
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveState(TrustState state)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(StatePath)!);
|
||||
FileUtil.WriteAllTextAtomic(StatePath, JsonSerializer.Serialize(state, new JsonSerializerOptions(ApiJson.Options) { WriteIndented = true }));
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
Log.Warn($"État de vérification des signatures non enregistré : {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,308 @@
|
||||
using System.Globalization;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Modules.Admin.Services;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Accounts;
|
||||
|
||||
/// <summary>Textes communs des pages « Administrateurs » et « Mes appareils ».</summary>
|
||||
public static class AccountTexts
|
||||
{
|
||||
/// <summary>« 01/11/2026 à 13:40 » (heure du PC).</summary>
|
||||
public static string Date(DateTimeOffset value) =>
|
||||
value.ToLocalTime().ToString("dd/MM/yyyy 'à' HH:mm", CultureInfo.InvariantCulture);
|
||||
|
||||
public static string Status(string status) => status switch
|
||||
{
|
||||
AdminStatus.Active => "Actif",
|
||||
AdminStatus.Pending => "En attente d'approbation",
|
||||
AdminStatus.Revoked => "Révoqué",
|
||||
_ => status
|
||||
};
|
||||
|
||||
/// <summary>Ce que permet chaque rôle (liste des rôles proposés).</summary>
|
||||
public static string RoleHint(string role) => role switch
|
||||
{
|
||||
AdminRoles.Owner => "tous les droits, dont la gestion des administrateurs",
|
||||
AdminRoles.Admin => "jeux, notes de version, publication et journal d'audit",
|
||||
AdminRoles.Editor => "informations des jeux et notes de version",
|
||||
_ => ""
|
||||
};
|
||||
|
||||
public static string KeyLabel(string? label) => string.IsNullOrWhiteSpace(label) ? "Appareil sans nom" : label;
|
||||
|
||||
/// <summary>Son propre appareil en attente : l'empreinte se lit sur le nouveau PC.</summary>
|
||||
public const string OwnPendingText = "Empreinte affichée sur le nouveau PC : saisissez-la ci-dessous pour l'approuver.";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Actions communes aux pages de comptes (approuver ou révoquer un appareil, supprimer une invitation,
|
||||
/// afficher un code d'invitation) : confirmation, appel signé, puis rechargement de la page.
|
||||
/// </summary>
|
||||
public abstract class AccountsPanelBase : AdminPanelViewModel
|
||||
{
|
||||
private InvitationCodeViewModel? _createdInvitation;
|
||||
private string? _statusMessage;
|
||||
|
||||
protected AccountsPanelBase(IAdminContext context) : base(context)
|
||||
{
|
||||
RefreshCommand = new AsyncRelayCommand(() => RunAsync(ReloadAsync), () => !IsBusy);
|
||||
}
|
||||
|
||||
public AsyncRelayCommand RefreshCommand { get; }
|
||||
|
||||
/// <summary>Faux pendant un échange avec le serveur (boutons des listes désactivés).</summary>
|
||||
public bool CanEdit => !IsBusy;
|
||||
|
||||
/// <summary>Résultat de la dernière action (« Appareil approuvé. »…).</summary>
|
||||
public string? StatusMessage
|
||||
{
|
||||
get => _statusMessage;
|
||||
protected set
|
||||
{
|
||||
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasStatus => !string.IsNullOrEmpty(StatusMessage);
|
||||
|
||||
/// <summary>Code d'une invitation tout juste créée : affiché une seule fois, jusqu'à « J'ai transmis le code ».</summary>
|
||||
public InvitationCodeViewModel? CreatedInvitation
|
||||
{
|
||||
get => _createdInvitation;
|
||||
private set
|
||||
{
|
||||
if (!SetProperty(ref _createdInvitation, value)) return;
|
||||
OnPropertyChanged(nameof(HasCreatedInvitation));
|
||||
OnCommandsChanged(); // pas de nouvelle invitation tant qu'un code non transmis est affiché
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasCreatedInvitation => _createdInvitation is not null;
|
||||
|
||||
protected override void OnBusyChanged()
|
||||
{
|
||||
OnPropertyChanged(nameof(CanEdit));
|
||||
RefreshCommand.RaiseCanExecuteChanged();
|
||||
OnCommandsChanged();
|
||||
}
|
||||
|
||||
/// <summary>Réévalue les commandes propres à la page.</summary>
|
||||
protected virtual void OnCommandsChanged()
|
||||
{
|
||||
}
|
||||
|
||||
protected override Task OnActivatedAsync(bool firstTime) => RunAsync(ReloadAsync);
|
||||
|
||||
/// <summary>Relit les données de la page sur le serveur.</summary>
|
||||
protected abstract Task ReloadAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Modification signée, puis rechargement SÉPARÉ : si seul le rechargement échoue, la modification
|
||||
/// (faite sur le serveur) est quand même annoncée, avec l'erreur du rechargement.
|
||||
/// </summary>
|
||||
protected async Task<bool> ApplyAsync(Func<Task> change, string success)
|
||||
{
|
||||
StatusMessage = null;
|
||||
if (!await RunAsync(change)) return false;
|
||||
StatusMessage = success;
|
||||
await RunAsync(ReloadAsync);
|
||||
return true;
|
||||
}
|
||||
|
||||
protected void ShowInvitation(InvitationCreated created, string title, string instructions)
|
||||
{
|
||||
StatusMessage = null;
|
||||
CreatedInvitation = new InvitationCodeViewModel(created.Code, title, instructions, created.Invitation.ExpiresAt,
|
||||
Context.Dialogs, () => CreatedInvitation = null);
|
||||
}
|
||||
|
||||
internal async Task ApproveAsync(AdminKeyItem key)
|
||||
{
|
||||
StatusMessage = null;
|
||||
var typed = AdminKeys.NormalizeFingerprint(key.FingerprintInput);
|
||||
if (typed is null)
|
||||
{
|
||||
ErrorMessage = "Empreinte incomplète : saisissez les 20 caractères (5 groupes de 4) affichés sur le nouvel appareil.";
|
||||
return;
|
||||
}
|
||||
if (!Context.Dialogs.Confirm($"Approuver l'appareil « {key.Label} » ?",
|
||||
$"{key.AdminName} pourra se connecter depuis cet appareil.\n\nN'approuvez que si l'empreinte vous a été " +
|
||||
"communiquée par la personne elle-même (téléphone, message), et non recopiée depuis un message reçu d'un inconnu.",
|
||||
"Approuver"))
|
||||
return;
|
||||
await ApplyAsync(() => Context.Session.ApproveKeyAsync(key.AdminId, key.KeyId, typed),
|
||||
$"Appareil « {key.Label} » de {key.AdminName} approuvé.");
|
||||
}
|
||||
|
||||
internal async Task RevokeKeyAsync(AdminKeyItem key)
|
||||
{
|
||||
if (!Context.Dialogs.Confirm($"Révoquer l'appareil « {key.Label} » ?",
|
||||
$"{key.AdminName} ne pourra plus se connecter depuis cet appareil ; les sessions qui l'utilisent sont fermées " +
|
||||
"immédiatement. Une clé révoquée ne peut pas être réactivée.", "Révoquer", danger: true))
|
||||
return;
|
||||
await ApplyAsync(() => Context.Session.RevokeKeyAsync(key.AdminId, key.KeyId),
|
||||
$"Appareil « {key.Label} » de {key.AdminName} révoqué.");
|
||||
}
|
||||
|
||||
internal async Task DeleteInvitationAsync(InvitationItem invitation)
|
||||
{
|
||||
if (!Context.Dialogs.Confirm("Supprimer l'invitation ?",
|
||||
$"{invitation.Title} : le code ne pourra plus être utilisé.", "Supprimer", danger: true))
|
||||
return;
|
||||
await ApplyAsync(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await Context.Session.DeleteInvitationAsync(invitation.Id);
|
||||
}
|
||||
catch (ApiException ex) when (ex.Status == 404)
|
||||
{
|
||||
// déjà utilisée ou expirée entre-temps
|
||||
}
|
||||
}, "Invitation supprimée.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Appareil (clé) d'un administrateur.</summary>
|
||||
public sealed class AdminKeyItem : ObservableObject
|
||||
{
|
||||
private string _fingerprintInput = "";
|
||||
private readonly string? _fingerprintWarning;
|
||||
|
||||
internal AdminKeyItem(AccountsPanelBase page, Guid adminId, string adminName, AdminKeySummary key, bool isCurrentSession,
|
||||
bool canApprove, string? approveHint, bool canRevoke, string pendingText)
|
||||
{
|
||||
// Cet appareil : empreinte calculée sur le PC (celle à dicter lors de la vérification du trousseau), pas celle
|
||||
// annoncée par le serveur ; une différence est signalée.
|
||||
var local = isCurrentSession ? page.Context.Session.LocalFingerprint : null;
|
||||
if (local is not null && key.Fingerprint is not null &&
|
||||
!string.Equals(AdminKeys.NormalizeFingerprint(local), AdminKeys.NormalizeFingerprint(key.Fingerprint), StringComparison.Ordinal))
|
||||
_fingerprintWarning = $" · ATTENTION : le serveur annonce une autre empreinte ({key.Fingerprint})";
|
||||
else if (local is not null)
|
||||
_fingerprintWarning = " · empreinte calculée sur ce PC";
|
||||
FingerprintText = local ?? key.Fingerprint ?? pendingText;
|
||||
AdminId = adminId;
|
||||
AdminName = adminName;
|
||||
KeyId = key.KeyId;
|
||||
Label = AccountTexts.KeyLabel(key.Label);
|
||||
Status = key.Status;
|
||||
IsCurrentSession = isCurrentSession;
|
||||
Fingerprint = local ?? key.Fingerprint;
|
||||
AddedText = $"Ajouté le {AccountTexts.Date(key.AddedAt)}";
|
||||
LastUsedText = key.LastUsedAt is { } used ? $"Dernière connexion le {AccountTexts.Date(used)}" : "Jamais utilisé";
|
||||
CanApprove = canApprove && IsPending;
|
||||
ApproveHint = IsPending ? approveHint : null;
|
||||
CanRevoke = canRevoke && !IsRevoked && !isCurrentSession;
|
||||
ApproveCommand = new AsyncRelayCommand(() => page.ApproveAsync(this), () => CanApprove);
|
||||
RevokeCommand = new AsyncRelayCommand(() => page.RevokeKeyAsync(this), () => CanRevoke);
|
||||
}
|
||||
|
||||
public Guid AdminId { get; }
|
||||
public string AdminName { get; }
|
||||
public string KeyId { get; }
|
||||
public string Label { get; }
|
||||
public string Status { get; }
|
||||
public string StatusText => IsCurrentSession ? "Cet appareil (session en cours)" : AccountTexts.Status(Status);
|
||||
public bool IsPending => Status == AdminStatus.Pending;
|
||||
public bool IsActive => Status == AdminStatus.Active;
|
||||
public bool IsRevoked => Status == AdminStatus.Revoked;
|
||||
public bool IsCurrentSession { get; }
|
||||
|
||||
/// <summary>Empreinte (clés actives ou révoquées) ; masquée par le serveur pour une clé en attente.</summary>
|
||||
public string? Fingerprint { get; }
|
||||
|
||||
/// <summary>Empreinte, ou (clé en attente) où la trouver : sur le nouvel appareil.</summary>
|
||||
public string FingerprintText { get; }
|
||||
public string AddedText { get; }
|
||||
public string LastUsedText { get; }
|
||||
public string DetailText => $"{AddedText} · {LastUsedText} · clé {KeyId}{_fingerprintWarning}";
|
||||
|
||||
public bool CanApprove { get; }
|
||||
|
||||
/// <summary>Pourquoi ce n'est pas vous qui approuvez (nouvel appareil d'un autre administrateur…).</summary>
|
||||
public string? ApproveHint { get; }
|
||||
|
||||
public bool HasApproveHint => !string.IsNullOrEmpty(ApproveHint);
|
||||
public bool CanRevoke { get; }
|
||||
|
||||
/// <summary>Empreinte dictée par la personne (« AB12 CD34 EF56 7890 1A2B »).</summary>
|
||||
public string FingerprintInput
|
||||
{
|
||||
get => _fingerprintInput;
|
||||
set => SetProperty(ref _fingerprintInput, value ?? "");
|
||||
}
|
||||
|
||||
public AsyncRelayCommand ApproveCommand { get; }
|
||||
public AsyncRelayCommand RevokeCommand { get; }
|
||||
}
|
||||
|
||||
/// <summary>Invitation en cours (code pas encore utilisé).</summary>
|
||||
public sealed class InvitationItem
|
||||
{
|
||||
internal InvitationItem(AccountsPanelBase page, InvitationSummary invitation, bool canDelete)
|
||||
{
|
||||
Id = invitation.Id;
|
||||
IsDevice = invitation.ForAdminId is not null;
|
||||
Title = IsDevice
|
||||
? $"Nouvel appareil de {invitation.DisplayName}"
|
||||
: $"{invitation.DisplayName} · {AdminTexts.Role(invitation.Role)}";
|
||||
DetailText = $"Créée par {invitation.CreatedBy ?? "le serveur"} le {AccountTexts.Date(invitation.CreatedAt)} · " +
|
||||
$"expire le {AccountTexts.Date(invitation.ExpiresAt)}";
|
||||
CanDelete = canDelete;
|
||||
DeleteCommand = new AsyncRelayCommand(() => page.DeleteInvitationAsync(this), () => CanDelete);
|
||||
}
|
||||
|
||||
public Guid Id { get; }
|
||||
public bool IsDevice { get; }
|
||||
public string Title { get; }
|
||||
public string DetailText { get; }
|
||||
public bool CanDelete { get; }
|
||||
public AsyncRelayCommand DeleteCommand { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Code d'une invitation tout juste créée. Le serveur n'en garde que l'empreinte : il n'est affiché
|
||||
/// qu'ici, une seule fois, et oublié dès que l'administrateur indique l'avoir transmis.
|
||||
/// </summary>
|
||||
public sealed class InvitationCodeViewModel : ObservableObject
|
||||
{
|
||||
private readonly IModuleDialogs _dialogs;
|
||||
private bool _copied;
|
||||
|
||||
internal InvitationCodeViewModel(string code, string title, string instructions, DateTimeOffset expiresAt,
|
||||
IModuleDialogs dialogs, Action dismiss)
|
||||
{
|
||||
Code = code;
|
||||
Title = title;
|
||||
Instructions = instructions;
|
||||
ExpiresText = $"Valable une seule fois, jusqu'au {AccountTexts.Date(expiresAt)}. Il ne sera plus affiché.";
|
||||
_dialogs = dialogs;
|
||||
CopyCommand = new RelayCommand(Copy);
|
||||
DismissCommand = new RelayCommand(dismiss);
|
||||
}
|
||||
|
||||
public string Code { get; }
|
||||
public string Title { get; }
|
||||
public string Instructions { get; }
|
||||
public string ExpiresText { get; }
|
||||
|
||||
public bool Copied
|
||||
{
|
||||
get => _copied;
|
||||
private set => SetProperty(ref _copied, value);
|
||||
}
|
||||
|
||||
public RelayCommand CopyCommand { get; }
|
||||
|
||||
/// <summary>« J'ai transmis le code » : le code disparaît de l'écran.</summary>
|
||||
public RelayCommand DismissCommand { get; }
|
||||
|
||||
private void Copy()
|
||||
{
|
||||
_dialogs.CopyToClipboard(Code);
|
||||
Copied = true;
|
||||
}
|
||||
}
|
||||
Loaded 100 of 220 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user