using System.Collections.Concurrent; using System.IO.Compression; using System.Security; using Microsoft.Extensions.Logging; using Microsoft.VisualBasic.CompilerServices; using SteamKit2; using SteamKit2.CDN; using SteamKit2.Internal; using Wabbajack.Common; using Wabbajack.DTOs.Interventions; using Wabbajack.DTOs.JsonConverters; using Wabbajack.Hashing.xxHash64; using Wabbajack.Networking.Http.Interfaces; using Wabbajack.Networking.Steam.DTOs; using Wabbajack.Networking.Steam.UserInterventions; using Wabbajack.Paths; using Wabbajack.Paths.IO; using Wabbajack.RateLimiter; namespace Wabbajack.Networking.Steam; public class Client : IDisposable { private readonly ILogger _logger; private readonly HttpClient _httpClient; private readonly SteamClient _client; private readonly SteamUser _steamUser; private readonly CallbackManager _manager; private readonly ITokenProvider _token; private TaskCompletionSource _loginTask; private TaskCompletionSource _connectTask; private readonly CancellationTokenSource _cancellationSource; private string? _twoFactorCode; private string? _authCode; private readonly IUserInterventionHandler _interventionHandler; private bool _isConnected; private bool _isLoggedIn; private bool _haveSigFile; public TaskCompletionSource _licenseRequest = new(); private readonly SteamApps _steamApps; private readonly DTOSerializer _dtos; private Server[] _cdnServers = Array.Empty(); private readonly IResource _limiter; public SteamApps.LicenseListCallback.License[] Licenses { get; private set; } public ConcurrentDictionary PackageTokens { get; } = new(); public ConcurrentDictionary PackageInfos { get; } = new(); public ConcurrentDictionary AppInfo { get; } = new(); public ConcurrentDictionary AppTokens { get; } = new(); public ConcurrentDictionary DepotKeys { get; } = new(); public Client(ILogger logger, HttpClient client, ITokenProvider token, IUserInterventionHandler interventionHandler, DTOSerializer dtos, IResource limiter) { _logger = logger; _httpClient = client; _dtos = dtos; _interventionHandler = interventionHandler; _limiter = limiter; _client = new SteamClient(SteamConfiguration.Create(c => { c.WithProtocolTypes(ProtocolTypes.WebSocket); c.WithUniverse(EUniverse.Public); })); _cancellationSource = new CancellationTokenSource(); _token = token; _manager = new CallbackManager(_client); _steamUser = _client.GetHandler()!; _steamApps = _client.GetHandler()!; _manager.Subscribe( OnConnected ); _manager.Subscribe( OnDisconnected ); _manager.Subscribe( OnLoggedOn ); _manager.Subscribe( OnLoggedOff ); _manager.Subscribe(OnLicenseList); _manager.Subscribe( OnUpdateMachineAuthCallback ); _isConnected = false; _isLoggedIn = false; _haveSigFile = false; new Thread(() => { while (!_cancellationSource.IsCancellationRequested) { _manager.RunWaitCallbacks(TimeSpan.FromMilliseconds(250)); } }) { Name = "Steam Client callback runner", IsBackground = true } .Start(); } private void OnLicenseList(SteamApps.LicenseListCallback obj) { if (obj.Result != EResult.OK) { _licenseRequest.TrySetException(new SteamException("While getting game information", obj.Result, EResult.Invalid)); } _logger.LogInformation("Steam has provided game information"); Licenses = obj.LicenseList.ToArray(); _licenseRequest.TrySetResult(); } private void OnUpdateMachineAuthCallback(SteamUser.UpdateMachineAuthCallback callback) { Task.Run(async () => { int fileSize; byte[] sentryHash; _logger.LogInformation("Got Steam machine auth info"); var token = await _token.Get(); var ms = new MemoryStream(); if (token?.SentryFile != null) await ms.WriteAsync(token.SentryFile); ms.Seek(callback.Offset, SeekOrigin.Begin); ms.Write(callback.Data, 0, callback.BytesToWrite); fileSize = (int) ms.Length; token!.SentryFile = ms.ToArray(); sentryHash = CryptoHelper.SHAHash(token.SentryFile); await _token.SetToken(token); _steamUser.SendMachineAuthResponse(new SteamUser.MachineAuthDetails { JobID = callback.JobID, FileName = callback.FileName, BytesWritten = callback.BytesToWrite, FileSize = token.SentryFile.Length, Offset = callback.Offset, Result = EResult.OK, LastError = 0, OneTimePassword = callback.OneTimePassword, SentryFileHash = sentryHash }); _haveSigFile = true; _loginTask.TrySetResult(); }); } private void OnLoggedOff(SteamUser.LoggedOffCallback obj) { _isLoggedIn = false; } private void OnLoggedOn(SteamUser.LoggedOnCallback callback) { Task.Run(async () => { var isSteamGuard = callback.Result == EResult.AccountLogonDenied; var is2FA = callback.Result == EResult.AccountLoginDeniedNeedTwoFactor; if (isSteamGuard || is2FA) { _logger.LogInformation("Account is SteamGuard protected"); if (is2FA) { var intervention = new GetAuthCode(GetAuthCode.AuthType.TwoFactorAuth); _interventionHandler.Raise(intervention); _twoFactorCode = await intervention.Task; } else { var intervention = new GetAuthCode(GetAuthCode.AuthType.EmailCode); _interventionHandler.Raise(intervention); _authCode = await intervention.Task; } var tcs = Login(_loginTask); return; } if (callback.Result != EResult.OK) { _loginTask.SetException(new SteamException("Unable to log in", callback.Result, callback.ExtendedResult)); return; } _isLoggedIn = true; _logger.LogInformation("Logged into Steam"); if (_haveSigFile) _loginTask.SetResult(); }); } private void OnDisconnected(SteamClient.DisconnectedCallback obj) { _isConnected = false; _logger.LogInformation("Logged out"); } private void OnConnected(SteamClient.ConnectedCallback obj) { Task.Run(async () => { var state = (await _token.Get())!; _logger.LogInformation("Connected to Steam, logging in"); byte[]? sentryHash = null; if (state.SentryFile != null) { _logger.LogInformation("Existing login keys found, reusing"); sentryHash = CryptoHelper.SHAHash(state.SentryFile); _haveSigFile = true; } else { _haveSigFile = false; } _isConnected = true; _steamUser.LogOn(new SteamUser.LogOnDetails { Username = state.User, Password = state.Password, AuthCode = _authCode, TwoFactorCode = _twoFactorCode, SentryFileHash = sentryHash, RequestSteam2Ticket = true }); // Reset the codes so we don't use them again _authCode = null; _twoFactorCode = null; }); } public Task Connect() { _connectTask = new TaskCompletionSource(); _client.Connect(); return _connectTask.Task; } public void Dispose() { _httpClient.Dispose(); _cancellationSource.Cancel(); _cancellationSource.Dispose(); } public async Task Login(TaskCompletionSource? tcs = null) { _loginTask = tcs ?? new TaskCompletionSource(); _logger.LogInformation("Attempting login"); _client.Connect(); await _loginTask.Task; await _licenseRequest.Task; } public async Task> GetPackageInfos(IEnumerable packageIds) { var packages = packageIds.Where(id => !PackageInfos.ContainsKey(id)).ToList(); if (packages.Count > 0) { var packageRequests = new List(); foreach (var package in packages) { var request = new SteamApps.PICSRequest(package); if (PackageTokens.TryGetValue(package, out var token)) { request.AccessToken = token; } packageRequests.Add(request); } _logger.LogInformation("Requesting {Count} package infos", packageRequests.Count); var results = await _steamApps.PICSGetProductInfo(new List(), packageRequests); if (results.Failed) throw new SteamException("Exception getting product info", EResult.Invalid, EResult.Invalid); foreach (var packageInfo in results.Results!) { foreach (var package in packageInfo.Packages.Select(v => v.Value)) { PackageInfos[package.ID] = package; } foreach (var package in packageInfo.UnknownPackages) { PackageInfos[package] = null; } } } return packages.Distinct().ToDictionary(p => p, p => PackageInfos[p]); } public async Task GetAppInfo(uint appId) { if (AppInfo.TryGetValue(appId, out var info)) return info.AppInfo; var result = await _steamApps.PICSGetAccessTokens(new List {appId}, new List()); if (result.AppTokensDenied.Contains(appId)) throw new SteamException($"Cannot get app token for {appId}", EResult.Invalid, EResult.Invalid); foreach (var token in result.AppTokens) { AppTokens[token.Key] = token.Value; } var request = new SteamApps.PICSRequest(appId); if (AppTokens.ContainsKey(appId)) { request.AccessToken = AppTokens[appId]; } var appResult = await _steamApps.PICSGetProductInfo(new List {request}, new List()); if (appResult.Failed) throw new SteamException($"Error getting app info for {appId}", EResult.Invalid, EResult.Invalid); foreach (var (_, value) in appResult.Results!.SelectMany(v => v.Apps)) { var translated = KeyValueTranslator.Translate(value.KeyValues, _dtos); AppInfo[value.ID] = (value, translated); } return AppInfo[appId].AppInfo; } public async Task LoadCDNServers() { if (_cdnServers.Length > 0) return _cdnServers; _logger.LogInformation("Loading CDN servers"); _cdnServers = (await ContentServerDirectoryService.LoadAsync(_client.Configuration)).ToArray(); _logger.LogInformation("{Count} servers found", _cdnServers.Length); return _cdnServers; } public async Task GetAppManifest(uint appId, uint depotId, ulong manifestId) { await LoadCDNServers(); var manifest = await CircuitBreaker.WithAutoRetryAsync(_logger, async () => { var client = _cdnServers.First(); var uri = new UriBuilder { Host = client.Host, Port = client.Port, Scheme = client.Protocol.ToString(), Path = $"depot/{depotId}/manifest/{manifestId}/5" }.Uri; var rawData = await _httpClient.GetByteArrayAsync(uri); using var zip = new ZipArchive(new MemoryStream(rawData)); var firstEntry = zip.Entries.First(); var data = new MemoryStream(); await using var entryStream = firstEntry.Open(); await entryStream.CopyToAsync(data); return DepotManifest.Deserialize(data.ToArray()); }); if (manifest.FilenamesEncrypted) manifest.DecryptFilenames(await GetDepotKey(depotId, appId)); return manifest; } public async ValueTask GetDepotKey(uint depotId, uint appId) { if (DepotKeys.ContainsKey(depotId)) return DepotKeys[depotId]; _logger.LogInformation("Requesting Depot Key for {DepotId}", depotId); var result = await _steamApps.GetDepotDecryptionKey(depotId, appId); if (result.Result != EResult.OK) throw new SteamException($"Error getting Depot Key for {depotId} {appId}", result.Result, EResult.Invalid); DepotKeys[depotId] = result.DepotKey; return result.DepotKey; } private static readonly Random _random = new(); private Server RandomServer() { return _cdnServers[_random.Next(0, _cdnServers.Length)]; } public async Task Download(uint appId, uint depotId, ulong manifest, DepotManifest.FileData fileData, AbsolutePath output, CancellationToken token, IJob? parentJob = null) { await LoadCDNServers(); var depotKey = await GetDepotKey(depotId, appId); await using var os = output.Open(FileMode.Create, FileAccess.Write, FileShare.Read); await fileData.Chunks.OrderBy(c => c.Offset) .PMapAll(async chunk => { async Task AttemptDownload(DepotManifest.ChunkData chunk) { var client = RandomServer(); using var job = await _limiter.Begin($"Downloading chunk of {fileData.FileName}", chunk.CompressedLength, token); var chunkId = chunk.ChunkID!.ToHex(); var uri = new UriBuilder { Host = client.Host, Port = client.Port, Scheme = client.Protocol.ToString(), Path = $"depot/{depotId}/chunk/{chunkId}" }.Uri; var data = await _httpClient.GetByteArrayAsync(uri, token); await job.Report(data.Length, token); if (parentJob != null) await parentJob.Report(data.Length, token); var chunkData = new DepotChunk(chunk, data); chunkData.Process(depotKey); return chunkData; } return await CircuitBreaker.WithAutoRetryAsync(_logger, () => AttemptDownload(chunk)); }).Do(async data => { await os.WriteAsync(data.Data, token); }); } }