Skip to content

Commit

Permalink
Merge pull request #1423 from tpill90/AddLancacheSupport
Browse files Browse the repository at this point in the history
Adding Lancache support to the CDN Client.
  • Loading branch information
xPaw authored Dec 29, 2024
2 parents 74f56fe + 35ec1cd commit 14e9a42
Show file tree
Hide file tree
Showing 3 changed files with 147 additions and 3 deletions.
18 changes: 15 additions & 3 deletions SteamKit2/SteamKit2/Steam/CDN/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ namespace SteamKit2.CDN
/// <summary>
/// The <see cref="Client"/> class is used for downloading game content from the Steam servers.
/// </summary>
public sealed class Client : IDisposable
public sealed partial class Client : IDisposable
{
HttpClient httpClient;

Expand All @@ -29,7 +29,6 @@ public sealed class Client : IDisposable
/// </summary>
public static TimeSpan ResponseBodyTimeout { get; set; } = TimeSpan.FromSeconds( 60 );


/// <summary>
/// Initializes a new instance of the <see cref="Client"/> class.
/// </summary>
Expand Down Expand Up @@ -230,7 +229,16 @@ public async Task<int> DownloadDepotChunkAsync( uint depotId, DepotManifest.Chun
var chunkID = Utils.EncodeHexString( chunk.ChunkID );
var url = $"depot/{depotId}/chunk/{chunkID}";

using var request = new HttpRequestMessage( HttpMethod.Get, BuildCommand( server, url, cdnAuthToken, proxyServer ) );
HttpRequestMessage request;
if ( UseLancacheServer )
{
request = BuildLancacheRequest( server, url, cdnAuthToken );
}
else
{
var builtUrl = BuildCommand( server, url, cdnAuthToken, proxyServer );
request = new HttpRequestMessage( HttpMethod.Get, builtUrl );
}

using var cts = new CancellationTokenSource();
cts.CancelAfter( RequestTimeout );
Expand Down Expand Up @@ -313,6 +321,10 @@ public async Task<int> 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 )
Expand Down
105 changes: 105 additions & 0 deletions SteamKit2/SteamKit2/Steam/CDN/ClientLancache.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/*
* 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
{
/// <summary>
/// When set to true, will attempt to download from a Lancache instance on the LAN rather than going out to Steam's CDNs.
/// </summary>
public static bool UseLancacheServer { get; private set; }

private static string TriggerDomain = "lancache.steamcontent.com";

/// <summary>
/// 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 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
/// </summary>
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;
}

/// <summary>
/// Determines if an IP address is a private address, as specified in RFC1918
/// </summary>
/// <param name="toTest">The IP address that will be tested</param>
/// <returns>Returns true if the IP is a private address, false if it isn't private</returns>
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;
}
}
}
27 changes: 27 additions & 0 deletions SteamKit2/Tests/CDNClientFacts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<HttpResponseMessage> SendAsync( HttpRequestMessage request, CancellationToken cancellationToken )
Expand Down

0 comments on commit 14e9a42

Please sign in to comment.