Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding Lancache support to the CDN Client. #1423

Merged
merged 5 commits into from
Dec 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 )
tpill90 marked this conversation as resolved.
Show resolved Hide resolved
{
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
Loading