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:
185
src/PSLauncher.Core/Lan/LanDiscoveryService.cs
Normal file
185
src/PSLauncher.Core/Lan/LanDiscoveryService.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user