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

@@ -0,0 +1,185 @@
using System.Collections.Concurrent;
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using PSLauncher.Models;
namespace PSLauncher.Core.Lan;
public sealed class LanDiscoveryService : BackgroundService, ILanDiscoveryService
{
private const int BeaconIntervalMs = 15_000;
private const int PeerTtlSeconds = 60;
private readonly Func<LanCacheConfig> _configProvider;
private readonly IZipCacheStore _cache;
private readonly ILogger<LanDiscoveryService> _logger;
private readonly ConcurrentDictionary<string, DiscoveredPeer> _peers = new(StringComparer.OrdinalIgnoreCase);
private string? _ourLanIp;
public LanDiscoveryService(
Func<LanCacheConfig> configProvider,
IZipCacheStore cache,
ILogger<LanDiscoveryService> logger)
{
_configProvider = configProvider;
_cache = cache;
_logger = logger;
}
public IReadOnlyList<DiscoveredPeer> ListDiscovered()
{
var cutoff = DateTime.UtcNow.AddSeconds(-PeerTtlSeconds);
return _peers.Values.Where(p => p.LastSeenUtc >= cutoff).ToList();
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var cfg = _configProvider();
if (!cfg.ServerEnabled && !cfg.ClientEnabled)
{
_logger.LogInformation("LAN discovery disabled (both ServerEnabled and ClientEnabled are false)");
return;
}
_ourLanIp = GetLocalLanIp();
// Listener (toujours actif si Client OU Server activé)
UdpClient? listener = null;
try
{
listener = new UdpClient(AddressFamily.InterNetwork);
listener.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
listener.Client.Bind(new IPEndPoint(IPAddress.Any, cfg.DiscoveryPort));
listener.EnableBroadcast = true;
_logger.LogInformation("LAN discovery listening on UDP :{Port}", cfg.DiscoveryPort);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to bind UDP discovery socket on port {Port}", cfg.DiscoveryPort);
listener?.Dispose();
return;
}
var listenTask = ListenLoopAsync(listener, stoppingToken);
// Broadcaster (uniquement si Server activé)
Task? broadcastTask = null;
if (cfg.ServerEnabled)
{
broadcastTask = BroadcastLoopAsync(cfg.DiscoveryPort, cfg.ServerPort, stoppingToken);
}
try
{
await Task.WhenAll(broadcastTask is null ? new[] { listenTask } : new[] { listenTask, broadcastTask })
.ConfigureAwait(false);
}
catch (OperationCanceledException) { /* expected on shutdown */ }
finally
{
try { listener.Close(); } catch { }
listener.Dispose();
}
}
private async Task ListenLoopAsync(UdpClient listener, CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try
{
var result = await listener.ReceiveAsync(ct).ConfigureAwait(false);
var senderHost = result.RemoteEndPoint.Address.ToString();
// Ignore nos propres beacons
if (_ourLanIp is not null && string.Equals(senderHost, _ourLanIp, StringComparison.OrdinalIgnoreCase))
continue;
var json = Encoding.UTF8.GetString(result.Buffer);
var beacon = JsonSerializer.Deserialize<BeaconPayload>(json);
if (beacon is null || beacon.V != 1) continue;
if (string.IsNullOrEmpty(beacon.Host) || beacon.Port <= 0 || beacon.Port > 65535) continue;
var peer = new DiscoveredPeer(
Host: beacon.Host,
Port: beacon.Port,
Versions: (IReadOnlyList<string>?)beacon.Versions ?? Array.Empty<string>(),
LastSeenUtc: DateTime.UtcNow);
_peers[beacon.Host] = peer;
}
catch (OperationCanceledException) { break; }
catch (Exception ex)
{
_logger.LogDebug(ex, "Discovery listen iteration failed (will retry)");
try { await Task.Delay(500, ct); } catch { break; }
}
}
}
private async Task BroadcastLoopAsync(int discoveryPort, int httpPort, CancellationToken ct)
{
using var sender = new UdpClient(AddressFamily.InterNetwork) { EnableBroadcast = true };
var endpoint = new IPEndPoint(IPAddress.Broadcast, discoveryPort);
while (!ct.IsCancellationRequested)
{
try
{
var versions = _cache.ListCached().Select(c => c.Version).ToList();
var host = _ourLanIp ?? GetLocalLanIp() ?? "0.0.0.0";
var payload = new BeaconPayload
{
V = 1,
Host = host,
Port = httpPort,
Versions = versions,
};
var json = JsonSerializer.Serialize(payload);
var bytes = Encoding.UTF8.GetBytes(json);
await sender.SendAsync(bytes, bytes.Length, endpoint).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Discovery broadcast failed (will retry)");
}
try { await Task.Delay(BeaconIntervalMs, ct); } catch { break; }
}
}
/// <summary>
/// Renvoie l'IPv4 LAN du PC : interface UP non-loopback avec une default
/// gateway IPv4. Filtre Hyper-V virtual switch, VPN sans gateway, etc.
/// Same logic that MainViewModel.LocalIpAddress uses for the sidebar info.
/// </summary>
public static string? GetLocalLanIp()
{
try
{
return NetworkInterface.GetAllNetworkInterfaces()
.Where(n => n.OperationalStatus == OperationalStatus.Up
&& n.NetworkInterfaceType != NetworkInterfaceType.Loopback
&& n.GetIPProperties().GatewayAddresses
.Any(g => g.Address.AddressFamily == AddressFamily.InterNetwork))
.SelectMany(n => n.GetIPProperties().UnicastAddresses)
.Where(a => a.Address.AddressFamily == AddressFamily.InterNetwork)
.Select(a => a.Address.ToString())
.FirstOrDefault();
}
catch { return null; }
}
private sealed class BeaconPayload
{
[JsonPropertyName("v")] public int V { get; set; }
[JsonPropertyName("host")] public string Host { get; set; } = "";
[JsonPropertyName("port")] public int Port { get; set; }
[JsonPropertyName("versions")] public List<string>? Versions { get; set; }
}
}