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:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user