From 8e4c2a88a6f9cc2cb1ba757d2b85f4cb4b6bcb62 Mon Sep 17 00:00:00 2001 From: Tim Pilius Date: Tue, 3 Sep 2024 21:11:17 -0400 Subject: [PATCH 1/5] Adding Lancache support to the CDN Client. It will automatically detect a lancache when it is available and point all traffic to go through it instead of Valve's cdns --- SteamKit2/SteamKit2/Steam/CDN/Client.cs | 42 ++++++++- .../SteamKit2/Steam/CDN/LancacheDetector.cs | 86 +++++++++++++++++++ 2 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 SteamKit2/SteamKit2/Steam/CDN/LancacheDetector.cs diff --git a/SteamKit2/SteamKit2/Steam/CDN/Client.cs b/SteamKit2/SteamKit2/Steam/CDN/Client.cs index 92f6800d4..de5b993df 100644 --- a/SteamKit2/SteamKit2/Steam/CDN/Client.cs +++ b/SteamKit2/SteamKit2/Steam/CDN/Client.cs @@ -10,6 +10,7 @@ using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using SteamKit2.Steam.CDN; namespace SteamKit2.CDN { @@ -29,6 +30,10 @@ public sealed class Client : IDisposable /// public static TimeSpan ResponseBodyTimeout { get; set; } = TimeSpan.FromSeconds( 60 ); + //TODO comment + private static readonly SemaphoreSlim _lancacheDetectionLock = new SemaphoreSlim( 1, 1 ); + private static bool CheckedForLancacheServer { get; set; } + private static bool LancacheDetected { get; set; } /// /// Initializes a new instance of the class. @@ -227,10 +232,31 @@ public async Task DownloadDepotChunkAsync( uint depotId, DepotManifest.Chun } } + await _lancacheDetectionLock.WaitAsync(); + try + { + if ( !CheckedForLancacheServer ) + { + LancacheDetected = await LancacheDetector.DetectLancacheServerAsync(httpClient); + } + } + finally + { + CheckedForLancacheServer = true; + _lancacheDetectionLock.Release(); + } + var chunkID = Utils.EncodeHexString( chunk.ChunkID ); var url = $"depot/{depotId}/chunk/{chunkID}"; - using var request = new HttpRequestMessage( HttpMethod.Get, BuildCommand( server, url, cdnAuthToken, proxyServer ) ); + var builtUrl = BuildCommand( server, url, cdnAuthToken, proxyServer ); + using var request = new HttpRequestMessage( HttpMethod.Get, builtUrl ); + if ( LancacheDetected ) + { + request.Headers.Host = server.Host; + // User agent must match the Steam client in order for Lancache to correctly identify and cache Valve's CDN content + request.Headers.Add( "User-Agent", "Valve/Steam HTTP Client 1.0" ); + } using var cts = new CancellationTokenSource(); cts.CancelAfter( RequestTimeout ); @@ -317,6 +343,20 @@ public async Task DownloadDepotChunkAsync( uint depotId, DepotManifest.Chun static Uri BuildCommand( Server server, string command, string? query, Server? proxyServer ) { + // If a Lancache instance is detected in Steam it will take priority over all other available servers + if ( LancacheDetected ) + { + var builder = new UriBuilder() + { + Scheme = "http", + Host = "lancache.steamcontent.com", + Port = 80, + Path = command, + Query = query ?? string.Empty + }; + return builder.Uri; + } + var uriBuilder = new UriBuilder { Scheme = server.Protocol == Server.ConnectionProtocol.HTTP ? "http" : "https", diff --git a/SteamKit2/SteamKit2/Steam/CDN/LancacheDetector.cs b/SteamKit2/SteamKit2/Steam/CDN/LancacheDetector.cs new file mode 100644 index 000000000..62ce73ea7 --- /dev/null +++ b/SteamKit2/SteamKit2/Steam/CDN/LancacheDetector.cs @@ -0,0 +1,86 @@ +/* + * This file is subject to the terms and conditions defined in + * file 'license.txt', which is part of this source code package. + */ + +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Threading.Tasks; + +namespace SteamKit2.Steam.CDN +{ + /// + /// Attempts to automatically resolve the Lancache's IP address in the same manner that the Steam client does. + /// + /// Will automatically try to detect the Lancache through the poisoned DNS entries. + /// This is a modified version from the original source : https://github.com/tpill90/lancache-prefill-common/blob/main/dotnet/LancacheIpResolver.cs + /// + public static class LancacheDetector + { + private static string TriggerDomain = "lancache.steamcontent.com"; + + public static async Task DetectLancacheServerAsync(HttpClient httpClient) + { + // Gets a list of ipv4 addresses, Lancache cannot use ipv6 currently + var ipAddresses = (await Dns.GetHostAddressesAsync( TriggerDomain ) ) + .Where(e => e.AddressFamily == AddressFamily.InterNetwork) + .ToArray(); + + // If there are no private IPs, then there can't be a Lancache instance. Lancache's IP must resolve to an RFC 1918 address + if (!ipAddresses.Any(e => e.IsPrivateAddress())) + { + return false; + } + + // DNS hostnames can possibly resolve to more than one IP address (one-to-many), so we must check each one for a Lancache server + foreach (var ip in ipAddresses) + { + try + { + // If the IP resolves to a private subnet, then we want to query the Lancache server to see if it is actually there. + // Requests that are served from the cache will have an additional header. + var response = await httpClient.GetAsync(new Uri($"http://{ip}/lancache-heartbeat")); + if (response.Headers.Contains("X-LanCache-Processed-By")) + { + Console.WriteLine($"Enabling local content cache at '{ip}' from lookup of lancache.steamcontent.com."); + return true; + } + } + catch (Exception e) when (e is HttpRequestException | e is TaskCanceledException) + { + // Target machine refused connection errors are to be expected if there is no Lancache at that IP address. + } + } + return false; + } + + /// + /// Determines if an IP address is a private address, as specified in RFC1918 + /// + /// The IP address that will be tested + /// Returns true if the IP is a private address, false if it isn't private + private static bool IsPrivateAddress( this IPAddress toTest ) + { + if ( IPAddress.IsLoopback( toTest ) ) + { + return true; + } + + byte[] bytes = toTest.GetAddressBytes(); + switch ( bytes[ 0 ] ) + { + case 10: + return true; + case 172: + return bytes[ 1 ] < 32 && bytes[ 1 ] >= 16; + case 192: + return bytes[ 1 ] == 168; + default: + return false; + } + } + } +} From cfe4cbf92d966df1564f30a43ff4f274fcd40489 Mon Sep 17 00:00:00 2001 From: Tim Pilius Date: Fri, 6 Sep 2024 14:54:04 -0400 Subject: [PATCH 2/5] Making suggested changes --- SteamKit2/SteamKit2/Steam/CDN/Client.cs | 15 ++------- .../SteamKit2/Steam/CDN/LancacheDetector.cs | 32 +++---------------- 2 files changed, 8 insertions(+), 39 deletions(-) diff --git a/SteamKit2/SteamKit2/Steam/CDN/Client.cs b/SteamKit2/SteamKit2/Steam/CDN/Client.cs index de5b993df..d9418e02d 100644 --- a/SteamKit2/SteamKit2/Steam/CDN/Client.cs +++ b/SteamKit2/SteamKit2/Steam/CDN/Client.cs @@ -30,8 +30,6 @@ public sealed class Client : IDisposable /// public static TimeSpan ResponseBodyTimeout { get; set; } = TimeSpan.FromSeconds( 60 ); - //TODO comment - private static readonly SemaphoreSlim _lancacheDetectionLock = new SemaphoreSlim( 1, 1 ); private static bool CheckedForLancacheServer { get; set; } private static bool LancacheDetected { get; set; } @@ -232,20 +230,13 @@ public async Task DownloadDepotChunkAsync( uint depotId, DepotManifest.Chun } } - await _lancacheDetectionLock.WaitAsync(); - try - { - if ( !CheckedForLancacheServer ) - { - LancacheDetected = await LancacheDetector.DetectLancacheServerAsync(httpClient); - } - } - finally + if ( !CheckedForLancacheServer ) { + LancacheDetected = LancacheDetector.DetectLancacheServer(); CheckedForLancacheServer = true; - _lancacheDetectionLock.Release(); } + var chunkID = Utils.EncodeHexString( chunk.ChunkID ); var url = $"depot/{depotId}/chunk/{chunkID}"; diff --git a/SteamKit2/SteamKit2/Steam/CDN/LancacheDetector.cs b/SteamKit2/SteamKit2/Steam/CDN/LancacheDetector.cs index 62ce73ea7..2f4bcb400 100644 --- a/SteamKit2/SteamKit2/Steam/CDN/LancacheDetector.cs +++ b/SteamKit2/SteamKit2/Steam/CDN/LancacheDetector.cs @@ -3,12 +3,9 @@ * file 'license.txt', which is part of this source code package. */ -using System; using System.Linq; using System.Net; -using System.Net.Http; using System.Net.Sockets; -using System.Threading.Tasks; namespace SteamKit2.Steam.CDN { @@ -22,38 +19,19 @@ public static class LancacheDetector { private static string TriggerDomain = "lancache.steamcontent.com"; - public static async Task DetectLancacheServerAsync(HttpClient httpClient) + public static bool DetectLancacheServer() { // Gets a list of ipv4 addresses, Lancache cannot use ipv6 currently - var ipAddresses = (await Dns.GetHostAddressesAsync( TriggerDomain ) ) + var ipAddresses = Dns.GetHostAddresses( TriggerDomain ) .Where(e => e.AddressFamily == AddressFamily.InterNetwork) .ToArray(); // If there are no private IPs, then there can't be a Lancache instance. Lancache's IP must resolve to an RFC 1918 address - if (!ipAddresses.Any(e => e.IsPrivateAddress())) + if (ipAddresses.Any(e => IsPrivateAddress( e ) )) { - return false; + return true; } - // DNS hostnames can possibly resolve to more than one IP address (one-to-many), so we must check each one for a Lancache server - foreach (var ip in ipAddresses) - { - try - { - // If the IP resolves to a private subnet, then we want to query the Lancache server to see if it is actually there. - // Requests that are served from the cache will have an additional header. - var response = await httpClient.GetAsync(new Uri($"http://{ip}/lancache-heartbeat")); - if (response.Headers.Contains("X-LanCache-Processed-By")) - { - Console.WriteLine($"Enabling local content cache at '{ip}' from lookup of lancache.steamcontent.com."); - return true; - } - } - catch (Exception e) when (e is HttpRequestException | e is TaskCanceledException) - { - // Target machine refused connection errors are to be expected if there is no Lancache at that IP address. - } - } return false; } @@ -62,7 +40,7 @@ public static async Task DetectLancacheServerAsync(HttpClient httpClient) /// /// The IP address that will be tested /// Returns true if the IP is a private address, false if it isn't private - private static bool IsPrivateAddress( this IPAddress toTest ) + private static bool IsPrivateAddress( IPAddress toTest ) { if ( IPAddress.IsLoopback( toTest ) ) { From 91a98c63c9cc9317bbd82af77e95d132e837c17e Mon Sep 17 00:00:00 2001 From: Tim Pilius Date: Fri, 8 Nov 2024 15:13:48 -0500 Subject: [PATCH 3/5] Third review changes --- SteamKit2/SteamKit2/Steam/CDN/Client.cs | 45 +++----- .../SteamKit2/Steam/CDN/ClientLancache.cs | 109 ++++++++++++++++++ .../SteamKit2/Steam/CDN/LancacheDetector.cs | 64 ---------- SteamKit2/Tests/CDNClientFacts.cs | 27 +++++ 4 files changed, 149 insertions(+), 96 deletions(-) create mode 100644 SteamKit2/SteamKit2/Steam/CDN/ClientLancache.cs delete mode 100644 SteamKit2/SteamKit2/Steam/CDN/LancacheDetector.cs diff --git a/SteamKit2/SteamKit2/Steam/CDN/Client.cs b/SteamKit2/SteamKit2/Steam/CDN/Client.cs index d9418e02d..931acb2cf 100644 --- a/SteamKit2/SteamKit2/Steam/CDN/Client.cs +++ b/SteamKit2/SteamKit2/Steam/CDN/Client.cs @@ -10,14 +10,13 @@ using System.Net.Http; using System.Threading; using System.Threading.Tasks; -using SteamKit2.Steam.CDN; namespace SteamKit2.CDN { /// /// The class is used for downloading game content from the Steam servers. /// - public sealed class Client : IDisposable + public partial class Client : IDisposable { HttpClient httpClient; @@ -30,9 +29,6 @@ public sealed class Client : IDisposable /// public static TimeSpan ResponseBodyTimeout { get; set; } = TimeSpan.FromSeconds( 60 ); - private static bool CheckedForLancacheServer { get; set; } - private static bool LancacheDetected { get; set; } - /// /// Initializes a new instance of the class. /// @@ -230,23 +226,18 @@ public async Task DownloadDepotChunkAsync( uint depotId, DepotManifest.Chun } } - if ( !CheckedForLancacheServer ) - { - LancacheDetected = LancacheDetector.DetectLancacheServer(); - CheckedForLancacheServer = true; - } - - var chunkID = Utils.EncodeHexString( chunk.ChunkID ); var url = $"depot/{depotId}/chunk/{chunkID}"; - var builtUrl = BuildCommand( server, url, cdnAuthToken, proxyServer ); - using var request = new HttpRequestMessage( HttpMethod.Get, builtUrl ); - if ( LancacheDetected ) + HttpRequestMessage request; + if ( UseLancacheServer ) + { + request = BuildLancacheRequest( server, url, cdnAuthToken ); + } + else { - request.Headers.Host = server.Host; - // User agent must match the Steam client in order for Lancache to correctly identify and cache Valve's CDN content - request.Headers.Add( "User-Agent", "Valve/Steam HTTP Client 1.0" ); + var builtUrl = BuildCommand( server, url, cdnAuthToken, proxyServer ); + request = new HttpRequestMessage( HttpMethod.Get, builtUrl ); } using var cts = new CancellationTokenSource(); @@ -330,24 +321,14 @@ public async Task DownloadDepotChunkAsync( uint depotId, DepotManifest.Chun DebugLog.WriteLine( nameof( CDN ), $"Failed to download a depot chunk {request.RequestUri}: {ex.Message}" ); throw; } + finally + { + request.Dispose(); + } } static Uri BuildCommand( Server server, string command, string? query, Server? proxyServer ) { - // If a Lancache instance is detected in Steam it will take priority over all other available servers - if ( LancacheDetected ) - { - var builder = new UriBuilder() - { - Scheme = "http", - Host = "lancache.steamcontent.com", - Port = 80, - Path = command, - Query = query ?? string.Empty - }; - return builder.Uri; - } - var uriBuilder = new UriBuilder { Scheme = server.Protocol == Server.ConnectionProtocol.HTTP ? "http" : "https", diff --git a/SteamKit2/SteamKit2/Steam/CDN/ClientLancache.cs b/SteamKit2/SteamKit2/Steam/CDN/ClientLancache.cs new file mode 100644 index 000000000..74bf51482 --- /dev/null +++ b/SteamKit2/SteamKit2/Steam/CDN/ClientLancache.cs @@ -0,0 +1,109 @@ +/* + * This file is subject to the terms and conditions defined in + * file 'license.txt', which is part of this source code package. + */ + +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Threading.Tasks; + +namespace SteamKit2.CDN +{ + public partial class Client + { + /// + /// When set to true, will attempt to download from a Lancache instance on the LAN rather than going out to Steam's CDNs. + /// + public static bool UseLancacheServer { get; private set; } + + private static string TriggerDomain = "lancache.steamcontent.com"; + + /// + /// Attempts to automatically resolve a Lancache on the local network. If detected, SteamKit will route all downloads through the cache + /// rather than through Steam's CDN. + /// + /// Will try to detect the Lancache through the poisoned DNS entries, however if that is not possible it will then check + /// 'localhost' to see if the Lancache is available locally. If the server is not available on 'localhost', then 172.17.0.1 will be checked to see if + /// the prefill is running from a docker container. + /// + /// This is a modified version from the original source : https://github.com/tpill90/lancache-prefill-common/blob/main/dotnet/LancacheIpResolver.cs + /// + public static async Task DetectLancacheServerAsync() + { + var ipAddresses = ( await Dns.GetHostAddressesAsync( TriggerDomain ) ) + .Where( e => e.AddressFamily == AddressFamily.InterNetwork || e.AddressFamily == AddressFamily.InterNetworkV6 ) + .ToArray(); + + if ( ipAddresses.Any( e => IsPrivateAddress(e) ) ) + { + UseLancacheServer = true; + return; + } + + //If there are no private IPs, then there can't be a Lancache instance. Lancache's IP must resolve to a private RFC 1918 address. + UseLancacheServer = false; + } + + /// + /// Determines if an IP address is a private address, as specified in RFC1918 + /// + /// The IP address that will be tested + /// Returns true if the IP is a private address, false if it isn't private + internal static bool IsPrivateAddress( IPAddress toTest ) + { + if ( IPAddress.IsLoopback( toTest ) ) + { + return true; + } + + byte[] bytes = toTest.GetAddressBytes(); + + // IPv4 + if ( toTest.AddressFamily == AddressFamily.InterNetwork ) + { + switch ( bytes[ 0 ] ) + { + case 10: + return true; + case 172: + return bytes[ 1 ] >= 16 && bytes[ 1 ] < 32; + case 192: + return bytes[ 1 ] == 168; + default: + return false; + } + } + + // IPv6 + if ( toTest.AddressFamily == AddressFamily.InterNetworkV6 ) + { + // Check for Unique Local Address (fc00::/7) and loopback (::1) + return ( bytes[ 0 ] & 0xFE ) == 0xFC || toTest.IsIPv6LinkLocal; + } + + return false; + } + + static HttpRequestMessage BuildLancacheRequest( Server server, string command, string? query) + { + var builder = new UriBuilder + { + Scheme = "http", + Host = "lancache.steamcontent.com", + Port = 80, + Path = command, + Query = query ?? string.Empty + }; + + var request = new HttpRequestMessage( HttpMethod.Get, builder.Uri ); + request.Headers.Host = server.Host; + // User agent must match the Steam client in order for Lancache to correctly identify and cache Valve's CDN content + request.Headers.Add( "User-Agent", "Valve/Steam HTTP Client 1.0" ); + + return request; + } + } +} diff --git a/SteamKit2/SteamKit2/Steam/CDN/LancacheDetector.cs b/SteamKit2/SteamKit2/Steam/CDN/LancacheDetector.cs deleted file mode 100644 index 2f4bcb400..000000000 --- a/SteamKit2/SteamKit2/Steam/CDN/LancacheDetector.cs +++ /dev/null @@ -1,64 +0,0 @@ -/* - * This file is subject to the terms and conditions defined in - * file 'license.txt', which is part of this source code package. - */ - -using System.Linq; -using System.Net; -using System.Net.Sockets; - -namespace SteamKit2.Steam.CDN -{ - /// - /// Attempts to automatically resolve the Lancache's IP address in the same manner that the Steam client does. - /// - /// Will automatically try to detect the Lancache through the poisoned DNS entries. - /// This is a modified version from the original source : https://github.com/tpill90/lancache-prefill-common/blob/main/dotnet/LancacheIpResolver.cs - /// - public static class LancacheDetector - { - private static string TriggerDomain = "lancache.steamcontent.com"; - - public static bool DetectLancacheServer() - { - // Gets a list of ipv4 addresses, Lancache cannot use ipv6 currently - var ipAddresses = Dns.GetHostAddresses( TriggerDomain ) - .Where(e => e.AddressFamily == AddressFamily.InterNetwork) - .ToArray(); - - // If there are no private IPs, then there can't be a Lancache instance. Lancache's IP must resolve to an RFC 1918 address - if (ipAddresses.Any(e => IsPrivateAddress( e ) )) - { - return true; - } - - return false; - } - - /// - /// Determines if an IP address is a private address, as specified in RFC1918 - /// - /// The IP address that will be tested - /// Returns true if the IP is a private address, false if it isn't private - private static bool IsPrivateAddress( IPAddress toTest ) - { - if ( IPAddress.IsLoopback( toTest ) ) - { - return true; - } - - byte[] bytes = toTest.GetAddressBytes(); - switch ( bytes[ 0 ] ) - { - case 10: - return true; - case 172: - return bytes[ 1 ] < 32 && bytes[ 1 ] >= 16; - case 192: - return bytes[ 1 ] == 168; - default: - return false; - } - } - } -} diff --git a/SteamKit2/Tests/CDNClientFacts.cs b/SteamKit2/Tests/CDNClientFacts.cs index 0c4c5ec40..7fe39a20d 100644 --- a/SteamKit2/Tests/CDNClientFacts.cs +++ b/SteamKit2/Tests/CDNClientFacts.cs @@ -119,6 +119,33 @@ public async Task ThrowsWhenDestinationBufferSmallerWithDepotKey() Assert.Equal( "destination", ex.ParamName ); } + [Theory] + [InlineData( "10.0.0.1", true )] // Private IPv4 (10.0.0.0/8) + [InlineData( "172.16.0.1", true )] // Private IPv4 (172.16.0.0/12) + [InlineData( "192.168.0.1", true )] // Private IPv4 (192.168.0.0/16) + [InlineData( "8.8.8.8", false )] // Public IPv4 + [InlineData( "127.0.0.1", true )] // Loopback IPv4 + public void IsPrivateAddress_IPv4Tests( string ipAddress, bool expected ) + { + IPAddress address = IPAddress.Parse( ipAddress ); + bool result = Client.IsPrivateAddress( address ); + + Assert.Equal( expected, result ); + } + + [Theory] + [InlineData( "fc00::1", true )] // Private IPv6 (Unique Local Address) + [InlineData( "fe80::1", true )] // Link-local IPv6 + [InlineData( "2001:db8::1", false )] // Public IPv6 + [InlineData( "::1", true )] // Loopback IPv6 + public void IsPrivateAddress_IPv6Tests( string ipAddress, bool expected ) + { + IPAddress address = IPAddress.Parse( ipAddress ); + bool result = Client.IsPrivateAddress( address ); + + Assert.Equal( expected, result ); + } + sealed class TeapotHttpMessageHandler : HttpMessageHandler { protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken ) From 49a989702de7fa56b4a2a92332840ca030ac4674 Mon Sep 17 00:00:00 2001 From: Tim Pilius Date: Thu, 14 Nov 2024 17:23:49 -0500 Subject: [PATCH 4/5] Removing inaccurate copy pasted comment --- SteamKit2/SteamKit2/Steam/CDN/ClientLancache.cs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/SteamKit2/SteamKit2/Steam/CDN/ClientLancache.cs b/SteamKit2/SteamKit2/Steam/CDN/ClientLancache.cs index 74bf51482..60324cefa 100644 --- a/SteamKit2/SteamKit2/Steam/CDN/ClientLancache.cs +++ b/SteamKit2/SteamKit2/Steam/CDN/ClientLancache.cs @@ -23,11 +23,7 @@ public partial class Client /// /// Attempts to automatically resolve a Lancache on the local network. If detected, SteamKit will route all downloads through the cache - /// rather than through Steam's CDN. - /// - /// Will try to detect the Lancache through the poisoned DNS entries, however if that is not possible it will then check - /// 'localhost' to see if the Lancache is available locally. If the server is not available on 'localhost', then 172.17.0.1 will be checked to see if - /// the prefill is running from a docker container. + /// rather than through Steam's CDN. Will try to detect the Lancache through the poisoned DNS entry for lancache.steamcontent.com /// /// This is a modified version from the original source : https://github.com/tpill90/lancache-prefill-common/blob/main/dotnet/LancacheIpResolver.cs /// From 35ec1cd89cc177ab5cd54cd7ab0ec8c9fb3d626a Mon Sep 17 00:00:00 2001 From: Tim Pilius Date: Thu, 5 Dec 2024 15:46:38 -0500 Subject: [PATCH 5/5] Adding sealed back --- SteamKit2/SteamKit2/Steam/CDN/Client.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SteamKit2/SteamKit2/Steam/CDN/Client.cs b/SteamKit2/SteamKit2/Steam/CDN/Client.cs index 931acb2cf..fa96b7e23 100644 --- a/SteamKit2/SteamKit2/Steam/CDN/Client.cs +++ b/SteamKit2/SteamKit2/Steam/CDN/Client.cs @@ -16,7 +16,7 @@ namespace SteamKit2.CDN /// /// The class is used for downloading game content from the Steam servers. /// - public partial class Client : IDisposable + public sealed partial class Client : IDisposable { HttpClient httpClient;