v0.25.4 — Cache LAN P2P : un PC sert les ZIPs aux autres du LAN

Feature : sur un site multi-PCs (salle VR), un PC peut servir les ZIPs PROSERVE
qu'il a déjà téléchargés aux autres PCs du LAN au lieu de les forcer à re-DL
14 Go depuis OVH. Auto-discovery par UDP broadcast, fallback transparent OVH
si aucun peer ne répond, intégrité préservée par SHA-256 du manifest signé Ed25519.

Architecture (src/PSLauncher.Core/Lan/) :
- ZipCacheStore : cache local des ZIPs téléchargés (sidecar SHA-256 pour servir
  sans re-hash). Pruning auto au top-N par mtime, protège les versions encore
  installées. Remplace le `File.Delete(zipPath)` post-install historique.
- LanCacheServer : serveur HTTP local sur TcpListener (PAS HttpListener — il
  exigeait une URL ACL admin qui aurait obligé à coder un netsh dans l'installer).
  Routes GET /v1/has/{ver} (probe) + GET /v1/zip/{ver} (Range support manuel,
  ~80 LOC de parsing HTTP). Filtre RFC1918/link-local sur RemoteEndPoint avant
  toute IO, regex stricte sur version (anti-path-traversal), SemaphoreSlim(4)
  pour rate-limiter à 4 DL concurrents.
- LanDiscoveryService : émet beacon UDP (255.255.255.255:47624, JSON avec host+
  port+versions) toutes les 15 s en mode serveur ; écoute en mode client/serveur
  avec TTL 60 s sur les peers. Pure .NET, zero NuGet (pas de mDNS deps).
- PeerSourceResolver : avant chaque DL, probe les peers (auto-découverts +
  manuels) avec timeout 1 s × max 5 peers. Vérifie size + SHA-256 contre le
  manifest avant de retourner une URL peer. Si null → fallback OVH transparent.

Modifs flow d'install (MainViewModel) :
- Avant GetSignedDownloadUrlAsync, tente PeerSourceResolver. Si peer trouvé,
  utilise son URL directement (le DownloadManager multi-segment marche tel quel).
- RefreshUrlAsync handler : pour les peers, retourne la même URL (pas d'expiration
  HMAC). Pour OVH, refresh du HMAC inchangé.
- Post-install : remplace File.Delete(zipPath) par ZipCacheStore.Promote + Prune.

Sidebar info (visible en permanence dans le coin bas-gauche) :
- Lignes "Serveur ON/KO/—", "Client ON/—", "Peers N" — refresh auto toutes les
  10 s via DispatcherTimer. Diagnostic instantané sans naviguer dans Settings.

Footer pendant le DL : préfixe différencié pour rendre la source évidente —
"📡 LAN 10.0.0.5 • v… : … Mo/s" vs "⬇ v… : … Mo/s" (OVH).

Settings → Cache LAN P2P : checkboxes ServerEnabled/ClientEnabled, ports HTTP/UDP,
MaxCachedVersions, statut serveur live, liste peers découverts, champ multi-line
peers manuels (override/fallback de l'auto-discovery). Toggle des flags →
prompt "Restart launcher requis" car les hosted services lisent leur config au
boot uniquement.

Fix critique au passage : App.OnStartup appelait .Build() mais JAMAIS .StartAsync()
sur l'IHost — du coup AUCUN HostedService n'a jamais tourné depuis l'introduction
du pattern. Ajout de StartAsync (fire-and-forget avec log d'erreur) et StopAsync
propre dans OnExit (timeout 5 s pour libérer les sockets sans TIME_WAIT bloquant).

Installer (PSLauncher.iss) : règles firewall TCP 47623 + UDP 47624 préposées
via netsh advfirewall, profile=private,domain (PAS public — safety net même
si l'utilisateur connecte le PC à un Wi-Fi public). Inutile si firewall OFF
(cas du déploiement actuel) mais pose les rails pour les déploiements futurs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-04 16:00:10 +02:00
parent 7de0c01b97
commit 65ff8541b7
18 changed files with 1553 additions and 43 deletions

View File

@@ -19,6 +19,7 @@ using PSLauncher.Core.Localization;
using PSLauncher.Core.Manifests;
using PSLauncher.Core.ApiTool;
using PSLauncher.Core.DocTool;
using PSLauncher.Core.Lan;
using PSLauncher.Core.Health;
using PSLauncher.Core.Migrations;
using PSLauncher.Core.Process;
@@ -51,12 +52,23 @@ public sealed partial class MainViewModel : ObservableObject
private readonly IReportToolDeployer _reportDeployer;
private readonly IDocToolDeployer _docDeployer;
private readonly IApiToolDeployer _apiDeployer;
private readonly IPeerSourceResolver _peerResolver;
private readonly IZipCacheStore _zipCacheStore;
private readonly ILanCacheServer _lanCacheServer;
private readonly ILanDiscoveryService _lanDiscovery;
private readonly ISystemHealthService _healthService;
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<MainViewModel> _logger;
private LicenseValidationResponse? _license;
/// <summary>
/// Host du peer LAN utilisé pour le DL en cours. Null = DL depuis OVH.
/// Sert à différencier visuellement la source dans le footer (📡 vs ⬇)
/// pendant TOUT le DL, pas juste 1 s avant que le progress prenne le relais.
/// </summary>
private string? _currentPeerHost;
private RemoteManifest? _lastManifest;
private CancellationTokenSource? _activeDownloadCts;
private VersionRowViewModel? _activeRow;
@@ -307,6 +319,28 @@ public sealed partial class MainViewModel : ObservableObject
}
}
/// <summary>
/// Statut du serveur LAN affiché dans la sidebar info :
/// "ON" si activé+actif, "KO" si activé mais le bind a échoué (port pris,
/// pas d'IP LAN…), "—" si désactivé. Refresh toutes les 10 s par timer.
/// </summary>
public string LanServerStatus
{
get
{
if (!_config.LanCache.ServerEnabled) return "—";
return _lanCacheServer.IsRunning ? "ON" : "KO";
}
}
/// <summary>"ON" / "—" selon le flag client. Pas de notion d'erreur côté client (probe à la demande).</summary>
public string LanClientStatus => _config.LanCache.ClientEnabled ? "ON" : "—";
/// <summary>Nombre de peers découverts auto via UDP (TTL 60 s côté LanDiscoveryService).</summary>
public int LanPeerCount => _lanDiscovery.ListDiscovered().Count;
private System.Windows.Threading.DispatcherTimer? _lanInfoRefreshTimer;
public MainViewModel(
IInstallationRegistry registry,
IProcessLauncher processLauncher,
@@ -323,6 +357,10 @@ public sealed partial class MainViewModel : ObservableObject
IReportToolDeployer reportDeployer,
IDocToolDeployer docDeployer,
IApiToolDeployer apiDeployer,
IPeerSourceResolver peerResolver,
IZipCacheStore zipCacheStore,
ILanCacheServer lanCacheServer,
ILanDiscoveryService lanDiscovery,
ISystemHealthService healthService,
IServiceProvider serviceProvider,
ILogger<MainViewModel> logger)
@@ -342,6 +380,10 @@ public sealed partial class MainViewModel : ObservableObject
_reportDeployer = reportDeployer;
_docDeployer = docDeployer;
_apiDeployer = apiDeployer;
_peerResolver = peerResolver;
_zipCacheStore = zipCacheStore;
_lanCacheServer = lanCacheServer;
_lanDiscovery = lanDiscovery;
_healthService = healthService;
_serviceProvider = serviceProvider;
_logger = logger;
@@ -371,6 +413,23 @@ public sealed partial class MainViewModel : ObservableObject
// contient au moins un. Premier passage immédiat puis re-check tous les
// RefreshIntervalSeconds.
StartHealthLoop();
// Rafraîchit le bloc info LAN de la sidebar toutes les 10 s. Le serveur
// peut transitionner ON↔KO sans interaction user (port pris…), et le
// peer count change au rythme des beacons UDP (15 s × TTL 60 s).
// DispatcherTimer.Tick s'exécute sur le UI thread → OnPropertyChanged
// peut updater les bindings WPF directement.
_lanInfoRefreshTimer = new System.Windows.Threading.DispatcherTimer(
TimeSpan.FromSeconds(10),
System.Windows.Threading.DispatcherPriority.Background,
(_, _) =>
{
OnPropertyChanged(nameof(LanServerStatus));
OnPropertyChanged(nameof(LanClientStatus));
OnPropertyChanged(nameof(LanPeerCount));
},
Application.Current.Dispatcher);
_lanInfoRefreshTimer.Start();
}
/// <summary>
@@ -899,20 +958,37 @@ public sealed partial class MainViewModel : ObservableObject
if (!dialog.DownloadRequested) return;
}
// 3) Download — tente d'abord d'obtenir une URL HMAC-signée par le serveur
// (endpoint /api/download-url/{version}). Fallback transparent sur l'URL
// publique du manifest si le serveur n'a pas le endpoint configuré.
// 3) Download — résolution d'URL en deux temps :
// A) Cache LAN : probe les peers (auto-découverts + manuels) ; si un
// d'eux a la version (taille + SHA-256 manifest match), DL depuis lui.
// Économise la bande passante OVH dans les setups multi-PCs.
// B) Sinon, fallback sur l'URL HMAC-signée serveur, puis sur l'URL
// publique du manifest si le endpoint /download-url n'est pas configuré.
row.State = VersionRowState.Downloading;
var signed = await _licenseService.GetSignedDownloadUrlAsync(row.Version, ct);
var urlString = signed ?? row.Remote.Download.Url;
var url = new Uri(urlString);
var peerSrc = await _peerResolver.TryResolveAsync(
row.Version, row.Remote.Download.SizeBytes, row.Remote.Download.Sha256, ct);
Uri url;
if (peerSrc is not null)
{
url = peerSrc.ZipUrl;
_currentPeerHost = peerSrc.PeerHost;
StatusMessage = Strings.StatusDownloadingFromPeer(peerSrc.PeerHost, row.Version);
}
else
{
_currentPeerHost = null;
var signed = await _licenseService.GetSignedDownloadUrlAsync(row.Version, ct);
var urlString = signed ?? row.Remote.Download.Url;
url = new Uri(urlString);
}
// Callback de refresh d'URL : appelé par DownloadManager quand un segment
// reçoit 403/410 (URL HMAC expirée mid-DL). Permet aux DLs très longs sur
// connexion lente (>= 6 h, le TTL serveur) de continuer transparemment.
// Si la license a été révoquée entre-temps, le callback renvoie null et le
// DL fail proprement.
// reçoit 403/410 (URL HMAC expirée mid-DL). Pour un peer LAN l'URL n'expire
// jamais, on retourne juste la même. Pour OVH, on rappelle GetSignedDownloadUrl
// (le TTL serveur HMAC est de 6 h, suffit pour la plupart des DL mais pas pour
// les très lentes connexions ADSL).
async Task<Uri?> RefreshUrlAsync(CancellationToken c)
{
if (peerSrc is not null) return peerSrc.ZipUrl;
var fresh = await _licenseService.GetSignedDownloadUrlAsync(row.Version, c);
return fresh is null ? null : new Uri(fresh);
}
@@ -975,7 +1051,20 @@ public sealed partial class MainViewModel : ObservableObject
});
await _zipInstaller.InstallAsync(zipPath, target, installProgress, ct);
try { File.Delete(zipPath); } catch { /* non critique */ }
// Promote le ZIP au cache LAN (sidecar SHA écrit) puis prune les vieilles
// versions selon MaxCachedVersions. Le ZIP est déjà au bon endroit
// (DownloadManager.VerifyAndFinalizeAsync l'a Move'd vers `proserve-X.zip`),
// donc PromoteAsync est essentiellement un no-op + écriture du sidecar.
// Les versions encore installées sont protégées du pruning.
try
{
await _zipCacheStore.PromoteAsync(zipPath, row.Version, row.Remote.Download.Sha256, ct);
await _zipCacheStore.PruneAsync(_config.LanCache.MaxCachedVersions, ct);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "ZIP cache promote/prune failed (non-critical)");
}
// 5) Migrations DB MySQL bundled dans _migrations/. Skip si la config
// a désactivé l'auto-apply ou si le ZIP ne contient pas de répertoire.
@@ -1186,6 +1275,7 @@ public sealed partial class MainViewModel : ObservableObject
_activeDownloadCts = null;
_activeRow = null;
_activeInstallTask = null;
_currentPeerHost = null;
IsBusy = false;
IsDownloadActive = false;
}
@@ -1291,7 +1381,12 @@ public sealed partial class MainViewModel : ObservableObject
}
var sb = new System.Text.StringBuilder();
sb.Append("⬇ v").Append(row.Version).Append(" : ");
// Préfixe différencié pour rendre la source du DL visible en permanence
// dans le footer : 📡 LAN (avec IP du peer) vs ⬇ Internet (OVH).
if (_currentPeerHost is not null)
sb.Append("📡 LAN ").Append(_currentPeerHost).Append(" • v").Append(row.Version).Append(" : ");
else
sb.Append("⬇ v").Append(row.Version).Append(" : ");
sb.Append(FormatSize(p.BytesDownloaded));
if (p.TotalBytes > 0) sb.Append(" / ").Append(FormatSize(p.TotalBytes));
if (p.BytesPerSecond > 0) sb.Append(" • ").Append(FormatSize((long)p.BytesPerSecond)).Append("/s");

View File

@@ -10,6 +10,7 @@ using PSLauncher.App.Views;
using PSLauncher.Core.Configuration;
using PSLauncher.Core.ApiTool;
using PSLauncher.Core.DocTool;
using PSLauncher.Core.Lan;
using PSLauncher.Core.Downloads;
using PSLauncher.Core.Installations;
using PSLauncher.Core.Licensing;
@@ -37,6 +38,9 @@ public sealed partial class SettingsViewModel : ObservableObject
private readonly IReportToolDeployer _reportDeployer;
private readonly IDocToolDeployer _docDeployer;
private readonly IApiToolDeployer _apiDeployer;
private readonly ILanCacheServer _lanCacheServer;
private readonly ILanDiscoveryService _lanDiscovery;
private readonly IZipCacheStore _zipCacheStore;
private readonly IInstallationRegistry _registry;
private readonly HttpClient _http;
private readonly ILogger<SettingsViewModel> _logger;
@@ -98,6 +102,20 @@ public sealed partial class SettingsViewModel : ObservableObject
= new();
public bool HasApiBackups => ApiBackups.Count > 0;
// ----- Cache LAN P2P -----
[ObservableProperty] private bool _lanServerEnabled;
[ObservableProperty] private bool _lanClientEnabled;
[ObservableProperty] private int _lanServerPort;
[ObservableProperty] private int _lanDiscoveryPort;
[ObservableProperty] private int _lanMaxCachedVersions;
/// <summary>Multi-line text bindé sur un TextBox : 1 URL par ligne.</summary>
[ObservableProperty] private string _lanManualPeers = string.Empty;
/// <summary>Statut serveur affiché (rafraîchi à l'ouverture des Settings et toutes les 5 s).</summary>
[ObservableProperty] private string _lanServerStatus = string.Empty;
/// <summary>Liste read-only des peers découverts via UDP, formaté pour l'UI.</summary>
public System.Collections.ObjectModel.ObservableCollection<string> LanDiscoveredPeers { get; } = new();
public bool HasLanDiscoveredPeers => LanDiscoveredPeers.Count > 0;
// ----- Health checks (bandeau de santé système) -----
/// <summary>
/// Liste éditable des checks affichés dans le bandeau de santé. Chaque ligne
@@ -179,6 +197,9 @@ public sealed partial class SettingsViewModel : ObservableObject
IReportToolDeployer reportDeployer,
IDocToolDeployer docDeployer,
IApiToolDeployer apiDeployer,
ILanCacheServer lanCacheServer,
ILanDiscoveryService lanDiscovery,
IZipCacheStore zipCacheStore,
IInstallationRegistry registry,
HttpClient http,
ILogger<SettingsViewModel> logger)
@@ -191,6 +212,9 @@ public sealed partial class SettingsViewModel : ObservableObject
_reportDeployer = reportDeployer;
_docDeployer = docDeployer;
_apiDeployer = apiDeployer;
_lanCacheServer = lanCacheServer;
_lanDiscovery = lanDiscovery;
_zipCacheStore = zipCacheStore;
_registry = registry;
_http = http;
_logger = logger;
@@ -224,6 +248,14 @@ public sealed partial class SettingsViewModel : ObservableObject
_apiAutoDeploy = config.ApiTool.AutoDeploy;
_apiMaxBackups = config.ApiTool.MaxBackups;
_lanServerEnabled = config.LanCache.ServerEnabled;
_lanClientEnabled = config.LanCache.ClientEnabled;
_lanServerPort = config.LanCache.ServerPort;
_lanDiscoveryPort = config.LanCache.DiscoveryPort;
_lanMaxCachedVersions = config.LanCache.MaxCachedVersions;
_lanManualPeers = string.Join(Environment.NewLine, config.LanCache.ManualPeerUrls ?? new List<string>());
RefreshLanStatus();
// Snapshot éditable des health checks. On clone chaque entry pour ne pas
// muter _config.HealthChecks.Checks tant que l'utilisateur n'a pas cliqué Save.
foreach (var entry in config.HealthChecks.Checks)
@@ -337,6 +369,18 @@ public sealed partial class SettingsViewModel : ObservableObject
(Language ?? "auto").ToLowerInvariant(),
StringComparison.Ordinal);
// Détecte les changements LAN qui nécessitent un restart du launcher.
// Les hosted services (LanCacheServer, LanDiscoveryService) lisent leur
// config UNE FOIS au démarrage de l'app — toggler ces flags après coup
// ne réveille pas le service mort. Le port aussi : changer pendant que
// le serveur tourne ne re-bind pas. ManualPeerUrls et MaxCachedVersions
// sont lus à chaque appel donc OK sans restart.
var lanRestartNeeded =
_config.LanCache.ServerEnabled != LanServerEnabled
|| _config.LanCache.ClientEnabled != LanClientEnabled
|| (LanServerEnabled && _config.LanCache.ServerPort != LanServerPort)
|| ((LanServerEnabled || LanClientEnabled) && _config.LanCache.DiscoveryPort != LanDiscoveryPort);
_config.ServerBaseUrl = ServerBaseUrl.TrimEnd('/');
_config.InstallRoot = InstallRoot;
_config.Language = Language ?? "auto";
@@ -369,6 +413,17 @@ public sealed partial class SettingsViewModel : ObservableObject
_config.ApiTool.AutoDeploy = ApiAutoDeploy;
_config.ApiTool.MaxBackups = Math.Max(0, ApiMaxBackups);
_config.LanCache.ServerEnabled = LanServerEnabled;
_config.LanCache.ClientEnabled = LanClientEnabled;
_config.LanCache.ServerPort = LanServerPort > 0 && LanServerPort <= 65535 ? LanServerPort : 47623;
_config.LanCache.DiscoveryPort = LanDiscoveryPort > 0 && LanDiscoveryPort <= 65535 ? LanDiscoveryPort : 47624;
_config.LanCache.MaxCachedVersions = Math.Max(0, LanMaxCachedVersions);
_config.LanCache.ManualPeerUrls = (LanManualPeers ?? string.Empty)
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Select(l => l.Trim())
.Where(l => l.Length > 0)
.ToList();
// Health checks : on remplace intégralement la liste persistée par
// les copies éditées. Les originaux restent en mémoire de toute façon
// (les VM les ont clonés), donc Cancel a déjà préservé l'état initial.
@@ -377,36 +432,42 @@ public sealed partial class SettingsViewModel : ObservableObject
_configStore.Save(_config);
_logger.LogInformation("Settings saved (language={Lang}, changed={Changed})", _config.Language, languageChanged);
// Helper local : prompt + spawn cmd qui attend la fermeture du launcher puis relance.
void PromptRestart(string message, string boxTitle, string logContext)
{
var ok = ThemedMessageBox.Show(
message, boxTitle,
MessageBoxButton.OKCancel, MessageBoxImage.Information, MessageBoxResult.OK);
if (ok != MessageBoxResult.OK) return;
try
{
var exe = System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName;
if (string.IsNullOrEmpty(exe)) return;
var cmd = $"/c (echo Wait && timeout /t 1 /nobreak > nul) & start \"\" \"{exe}\"";
Process.Start(new ProcessStartInfo
{
FileName = "cmd.exe",
Arguments = cmd,
UseShellExecute = false,
CreateNoWindow = true,
});
Application.Current.Shutdown();
}
catch (Exception ex) { _logger.LogError(ex, "Restart on {Context} failed", logContext); }
}
if (languageChanged)
{
// Le launcher doit être relancé pour que les bindings x:Static reflètent
// la nouvelle langue. Plutôt que de demander à l'utilisateur de fermer/rouvrir,
// on spawne un cmd qui attend la fermeture puis relance.
var ok = ThemedMessageBox.Show(
Strings.MsgLanguageRestart,
Strings.MsgBoxLanguageChange,
MessageBoxButton.OKCancel, MessageBoxImage.Information, MessageBoxResult.OK);
if (ok == MessageBoxResult.OK)
{
try
{
var exe = System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName;
if (!string.IsNullOrEmpty(exe))
{
var pid = Environment.ProcessId;
var cmd = $"/c (echo Wait && timeout /t 1 /nobreak > nul) & start \"\" \"{exe}\"";
Process.Start(new ProcessStartInfo
{
FileName = "cmd.exe",
Arguments = cmd,
UseShellExecute = false,
CreateNoWindow = true,
});
Application.Current.Shutdown();
}
}
catch (Exception ex) { _logger.LogError(ex, "Restart on language change failed"); }
}
// la nouvelle langue. (Si LAN a aussi changé, le même restart le couvre.)
PromptRestart(Strings.MsgLanguageRestart, Strings.MsgBoxLanguageChange, "language change");
}
else if (lanRestartNeeded)
{
// Les hosted services LAN lisent leur config au démarrage uniquement.
// Sans restart, toggler ServerEnabled / ClientEnabled ne réveille pas
// le service mort.
PromptRestart(Strings.MsgLanCacheRestart, Strings.MsgBoxLanCacheChange, "LAN cache toggle");
}
}
@@ -857,6 +918,38 @@ public sealed partial class SettingsViewModel : ObservableObject
}
}
// ===================== CACHE LAN P2P =====================
/// <summary>
/// Rafraîchit les indicateurs LAN (statut serveur, liste des peers découverts).
/// Appelé à l'init + via la commande "Actualiser" dans la UI.
/// </summary>
[RelayCommand]
private void RefreshLanStatus()
{
// Statut serveur
if (_lanCacheServer.IsRunning && _lanCacheServer.BoundEndpoints.Count > 0)
{
var versions = _zipCacheStore.ListCached().Count;
LanServerStatus = Strings.SettingsLanCacheServerStatus(
string.Join(", ", _lanCacheServer.BoundEndpoints), versions);
}
else
{
LanServerStatus = Strings.SettingsLanCacheServerOff;
}
// Liste des peers découverts (formaté pour l'affichage)
var peers = _lanDiscovery.ListDiscovered();
LanDiscoveredPeers.Clear();
foreach (var p in peers)
{
var versions = p.Versions.Count > 0 ? string.Join(", ", p.Versions) : "—";
LanDiscoveredPeers.Add($"http://{p.Host}:{p.Port} • versions: [{versions}]");
}
OnPropertyChanged(nameof(HasLanDiscoveredPeers));
}
[RelayCommand]
private async Task RedeployApiAsync()
{