Skip to content

Commit

Permalink
Adding Lancache support to the CDN Client. It will automatically dete…
Browse files Browse the repository at this point in the history
…ct a lancache when it is available and point all traffic to go through it instead of Valve's cdns
  • Loading branch information
tpill90 committed Nov 8, 2024
1 parent 0c4db89 commit 2b1c698
Show file tree
Hide file tree
Showing 2 changed files with 127 additions and 1 deletion.
42 changes: 41 additions & 1 deletion SteamKit2/SteamKit2/Steam/CDN/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using SteamKit2.Steam.CDN;

namespace SteamKit2.CDN
{
Expand All @@ -29,6 +30,10 @@ public sealed class Client : IDisposable
/// </summary>
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; }

/// <summary>
/// Initializes a new instance of the <see cref="Client"/> class.
Expand Down Expand Up @@ -227,10 +232,31 @@ public async Task<int> 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 );
Expand Down Expand Up @@ -317,6 +343,20 @@ public async Task<int> 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",
Expand Down
86 changes: 86 additions & 0 deletions SteamKit2/SteamKit2/Steam/CDN/LancacheDetector.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// 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
/// </summary>
public static class LancacheDetector
{
private static string TriggerDomain = "lancache.steamcontent.com";

public static async Task<bool> 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;
}

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

0 comments on commit 2b1c698

Please sign in to comment.