using System; using System.IO; using System.Linq; using System.Net; using System.Net.Http; using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Hosting; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using SteamAuth; using SteamKit2; using SteamKit2.Authentication; var builder = WebApplication.CreateBuilder(args); // ── 限制每个主机的最大并发连接数,防止连接耗尽 ── System.Net.ServicePointManager.DefaultConnectionLimit = 200; var app = builder.Build(); // ── 全局异常处理:防止未捕获的异常导致进程崩溃 ── app.UseExceptionHandler(errorApp => { errorApp.Run(async context => { context.Response.StatusCode = 500; context.Response.ContentType = "application/json; charset=utf-8"; var errorId = Guid.NewGuid().ToString("N")[..8]; Console.Error.WriteLine($"[UNHANDLED] error_id={errorId} path={context.Request.Path}"); await context.Response.WriteAsync($"{{\"error\":\"Internal server error\",\"error_id\":\"{errorId}\"}}"); }); }); // ── 请求超时中间件:防止慢请求永久占用线程 ── app.Use(async (context, next) => { using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60)); try { await next.Invoke(); } catch (OperationCanceledException) { context.Response.StatusCode = 504; context.Response.ContentType = "application/json"; await context.Response.WriteAsync("{\"error\":\"Request timeout\"}"); } }); app.MapPost("/login", async (HttpContext http) => { var req = await GetRequestBodyAsync(http); if (req == null) { http.Response.StatusCode = 400; await http.Response.WriteAsync("invalid request"); return; } if (!File.Exists(req.MaFilePath)) { http.Response.StatusCode = 400; await http.Response.WriteAsync("maFile not found"); return; } SteamGuardAccount? account; try { var contents = await File.ReadAllTextAsync(req.MaFilePath); account = JsonConvert.DeserializeObject(contents); } catch (Exception ex) { http.Response.StatusCode = 500; await http.Response.WriteAsync("failed to read maFile: " + ex.Message); return; } try { // Start SteamClient and authenticate using the maFile-provided SteamGuardAccount SteamClient steamClient = CreateSteamClient(req.ProxyUrl, app.Logger, "login"); steamClient.Connect(); var timeoutMs = 15000; var waited = 0; while (!steamClient.IsConnected && waited < timeoutMs) { await Task.Delay(200); waited += 200; } if (!steamClient.IsConnected) { http.Response.StatusCode = 500; await http.Response.WriteAsync("failed to connect to steam network"); return; } CredentialsAuthSession authSession = await steamClient.Authentication.BeginAuthSessionViaCredentialsAsync(new AuthSessionDetails { Username = req.Username, Password = req.Password, IsPersistentSession = false, // PlatformType and ClientOSType are optional; omit to avoid enum symbol differences across SteamKit2 versions. Authenticator = new FileAuthenticator(account), }); var pollResponse = await authSession.PollingWaitForResultAsync(); var sessionData = new SessionData() { SteamID = authSession.SteamID.ConvertToUInt64(), AccessToken = pollResponse.AccessToken, RefreshToken = pollResponse.RefreshToken, }; // Serialize cookies var uri = new Uri("https://steamcommunity.com"); var cookies = sessionData.GetCookies().GetCookies(uri).Cast().Select(c => new { name = c.Name, value = c.Value, domain = c.Domain, path = c.Path, expires = c.Expires == DateTime.MinValue ? null : (DateTime?)c.Expires }).ToArray(); var resp = new { session = new { steamId = sessionData.SteamID, accessToken = sessionData.AccessToken, refreshToken = sessionData.RefreshToken, sessionId = sessionData.SessionID }, cookies = cookies }; http.Response.ContentType = "application/json"; await http.Response.WriteAsync(JsonConvert.SerializeObject(resp, Formatting.Indented)); return; } catch (Exception ex) { http.Response.StatusCode = 500; await http.Response.WriteAsync("login failed: " + ex.Message); return; } }); app.MapPost("/wallet-balance", async (HttpContext http) => { var req = await GetWalletBalanceRequestBodyAsync(http); if (req == null || string.IsNullOrWhiteSpace(req.SteamLoginSecure)) { http.Response.StatusCode = 400; await http.Response.WriteAsync("invalid request"); return; } try { var handler = CreateHttpClientHandler(req.ProxyUrl, app.Logger, "wallet-balance"); using var client = new HttpClient(handler, disposeHandler: true) { Timeout = TimeSpan.FromSeconds(20) }; var request = new HttpRequestMessage(HttpMethod.Get, "https://store.steampowered.com/api/getfundwalletinfo/?cc=us&l=english"); request.Headers.Add("Cookie", $"steamLoginSecure={req.SteamLoginSecure}"); var response = await client.SendAsync(request); var body = await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode) { http.Response.StatusCode = 500; await http.Response.WriteAsync($"wallet request failed: {(int)response.StatusCode}"); return; } var json = JsonConvert.DeserializeObject(body); if (json == null || json["success"]?.Value() != 1 || json["user_wallet"] is not JObject wallet) { http.Response.StatusCode = 500; await http.Response.WriteAsync("wallet response invalid"); return; } var resp = new { amount = wallet["amount"]?.Value() ?? "0", currency = wallet["currency"]?.Value() ?? "USD" }; http.Response.ContentType = "application/json"; await http.Response.WriteAsync(JsonConvert.SerializeObject(resp)); } catch (Exception ex) { http.Response.StatusCode = 500; await http.Response.WriteAsync("wallet query failed: " + ex.Message); } }); app.MapPost("/trade-confirmations/list", async (HttpContext http) => { var req = await GetTradeConfirmationsListRequestBodyAsync(http); if (req == null || string.IsNullOrWhiteSpace(req.MaFilePath) || req.Session == null) { http.Response.StatusCode = 400; await http.Response.WriteAsync("invalid request"); return; } if (!File.Exists(req.MaFilePath)) { http.Response.StatusCode = 400; await http.Response.WriteAsync("maFile not found"); return; } try { var (proxyUrl, source) = ResolveProxyUrl(req.ProxyUrl); app.Logger.LogInformation( "[{Endpoint}] Proxy resolved. source={Source}, value={Proxy}", "trade-confirmations/list", source, MaskProxyUrlForLog(proxyUrl) ); app.Logger.LogInformation( "[{Endpoint}] Calling Steam confirmation list. mode={Mode}, proxy={Proxy}", "trade-confirmations/list", string.IsNullOrWhiteSpace(proxyUrl) ? "direct" : "proxy", MaskProxyUrlForLog(proxyUrl) ); var account = await BuildAccountWithSessionAsync(req.MaFilePath, req.Session); if (account == null) { http.Response.StatusCode = 500; await http.Response.WriteAsync("failed to build steam account session"); return; } if (account.Session.IsRefreshTokenExpired()) { http.Response.StatusCode = 401; await http.Response.WriteAsync("session refresh token expired"); return; } var accessTokenRefreshed = false; if (account.Session.IsAccessTokenExpired()) { await account.Session.RefreshAccessToken(); accessTokenRefreshed = true; } var confirmations = await account.FetchConfirmationsAsync(proxyUrl); var resp = new { session = new { steamId = account.Session.SteamID, accessToken = account.Session.AccessToken, refreshToken = account.Session.RefreshToken, sessionId = account.Session.SessionID, accessTokenRefreshed }, confirmations = confirmations.Select(c => new { id = c.ID, key = c.Key, creatorId = c.Creator, type = c.ConfType.ToString(), headline = c.Headline, summary = c.Summary, accept = c.Accept, cancel = c.Cancel, icon = c.Icon }).ToArray() }; http.Response.ContentType = "application/json"; await http.Response.WriteAsync(JsonConvert.SerializeObject(resp)); } catch (Exception ex) { http.Response.StatusCode = 500; await http.Response.WriteAsync("fetch confirmations failed: " + ex.Message); } }); app.MapPost("/trade-confirmations/operate", async (HttpContext http) => { var req = await GetTradeConfirmationOperateRequestBodyAsync(http); if (req == null || string.IsNullOrWhiteSpace(req.MaFilePath) || req.Session == null || req.ConfirmationId == 0 || string.IsNullOrWhiteSpace(req.Operation)) { http.Response.StatusCode = 400; await http.Response.WriteAsync("invalid request"); return; } if (!File.Exists(req.MaFilePath)) { http.Response.StatusCode = 400; await http.Response.WriteAsync("maFile not found"); return; } var op = req.Operation.Trim().ToLowerInvariant(); if (op != "accept" && op != "cancel") { http.Response.StatusCode = 400; await http.Response.WriteAsync("invalid operation, expected accept or cancel"); return; } try { var (proxyUrl, source) = ResolveProxyUrl(req.ProxyUrl); app.Logger.LogInformation( "[{Endpoint}] Proxy resolved. source={Source}, value={Proxy}", "trade-confirmations/operate", source, MaskProxyUrlForLog(proxyUrl) ); app.Logger.LogInformation( "[{Endpoint}] Calling Steam confirmation operation. op={Op}, mode={Mode}, proxy={Proxy}", "trade-confirmations/operate", op, string.IsNullOrWhiteSpace(proxyUrl) ? "direct" : "proxy", MaskProxyUrlForLog(proxyUrl) ); var account = await BuildAccountWithSessionAsync(req.MaFilePath, req.Session); if (account == null) { http.Response.StatusCode = 500; await http.Response.WriteAsync("failed to build steam account session"); return; } if (account.Session.IsRefreshTokenExpired()) { http.Response.StatusCode = 401; await http.Response.WriteAsync("session refresh token expired"); return; } var accessTokenRefreshed = false; if (account.Session.IsAccessTokenExpired()) { await account.Session.RefreshAccessToken(); accessTokenRefreshed = true; } ulong confirmationKey = req.ConfirmationKey ?? 0; ulong effectiveConfirmationId = req.ConfirmationId; var keyResolvedFromList = false; var idResolvedFromList = false; var matchedBy = "request"; if (confirmationKey == 0) { var confirmations = await account.FetchConfirmationsAsync(proxyUrl); var matched = confirmations.FirstOrDefault(c => c.ID == req.ConfirmationId); if (matched != null) { matchedBy = "id"; } else { // buylisting 返回的 confirmation_id 在部分场景为 creator_id, // 这里回退按 creator_id 匹配,便于调用方仅传一个确认订单号即可完成确认。 matched = confirmations.FirstOrDefault(c => c.Creator == req.ConfirmationId); if (matched != null) { matchedBy = "creator_id"; } } if (matched == null || matched.Key == 0) { http.Response.StatusCode = 404; await http.Response.WriteAsync("confirmation not found or key unavailable"); return; } effectiveConfirmationId = matched.ID; confirmationKey = matched.Key; keyResolvedFromList = true; idResolvedFromList = true; } var confirmation = new Confirmation { ID = effectiveConfirmationId, Key = confirmationKey }; var success = op == "accept" ? await account.AcceptConfirmation(confirmation, proxyUrl) : await account.DenyConfirmation(confirmation, proxyUrl); var resp = new { success, operation = op, requestedConfirmationId = req.ConfirmationId, confirmationId = effectiveConfirmationId, confirmationKey, keyResolvedFromList, idResolvedFromList, matchedBy, session = new { steamId = account.Session.SteamID, accessToken = account.Session.AccessToken, refreshToken = account.Session.RefreshToken, sessionId = account.Session.SessionID, accessTokenRefreshed } }; http.Response.ContentType = "application/json"; await http.Response.WriteAsync(JsonConvert.SerializeObject(resp)); } catch (Exception ex) { http.Response.StatusCode = 500; await http.Response.WriteAsync("operate confirmation failed: " + ex.Message); } }); app.Run(); async Task GetRequestBodyAsync(HttpContext http) { try { using var sr = new StreamReader(http.Request.Body); var body = await sr.ReadToEndAsync(); if (string.IsNullOrWhiteSpace(body)) return null; return JsonConvert.DeserializeObject(body); } catch { return null; } } async Task GetWalletBalanceRequestBodyAsync(HttpContext http) { try { using var sr = new StreamReader(http.Request.Body); var body = await sr.ReadToEndAsync(); if (string.IsNullOrWhiteSpace(body)) return null; return JsonConvert.DeserializeObject(body); } catch { return null; } } async Task GetTradeConfirmationsListRequestBodyAsync(HttpContext http) { try { using var sr = new StreamReader(http.Request.Body); var body = await sr.ReadToEndAsync(); if (string.IsNullOrWhiteSpace(body)) return null; return JsonConvert.DeserializeObject(body); } catch { return null; } } async Task GetTradeConfirmationOperateRequestBodyAsync(HttpContext http) { try { using var sr = new StreamReader(http.Request.Body); var body = await sr.ReadToEndAsync(); if (string.IsNullOrWhiteSpace(body)) return null; return JsonConvert.DeserializeObject(body); } catch { return null; } } async Task BuildAccountWithSessionAsync(string maFilePath, SessionPayload sessionPayload) { var contents = await File.ReadAllTextAsync(maFilePath); var account = JsonConvert.DeserializeObject(contents); if (account == null) { return null; } if (string.IsNullOrWhiteSpace(account.DeviceID)) { throw new Exception("maFile missing device_id"); } account.Session = new SessionData { SteamID = sessionPayload.SteamId, AccessToken = sessionPayload.AccessToken, RefreshToken = sessionPayload.RefreshToken, SessionID = sessionPayload.SessionId }; return account; } SteamClient CreateSteamClient(string? requestProxyUrl, ILogger logger, string endpointName) { var (proxyUrl, source) = ResolveProxyUrl(requestProxyUrl); logger.LogInformation( "[{Endpoint}] Proxy resolved. source={Source}, value={Proxy}", endpointName, source, MaskProxyUrlForLog(proxyUrl) ); if (string.IsNullOrWhiteSpace(proxyUrl)) { logger.LogInformation("[{Endpoint}] Proxy disabled for this request.", endpointName); return new SteamClient(); } if (!Uri.TryCreate(proxyUrl, UriKind.Absolute, out var proxyUri)) { logger.LogWarning( "[{Endpoint}] Proxy url invalid. raw={RawProxy}", endpointName, MaskProxyUrlForLog(proxyUrl) ); throw new ArgumentException("invalid proxy url, expected format like http://127.0.0.1:7890"); } var config = SteamConfiguration.Create(builder => { // Force WebSocket so proxy can be applied through HttpClient. builder.WithProtocolTypes(ProtocolTypes.WebSocket); builder.WithHttpClientFactory(() => { var handler = new HttpClientHandler { Proxy = new WebProxy(proxyUri), UseProxy = true }; return new HttpClient(handler, disposeHandler: true); }); }); return new SteamClient(config); } HttpClientHandler CreateHttpClientHandler(string? requestProxyUrl, ILogger logger, string endpointName) { var (proxyUrl, source) = ResolveProxyUrl(requestProxyUrl); logger.LogInformation( "[{Endpoint}] Proxy resolved. source={Source}, value={Proxy}", endpointName, source, MaskProxyUrlForLog(proxyUrl) ); var handler = new HttpClientHandler(); if (string.IsNullOrWhiteSpace(proxyUrl)) { logger.LogInformation("[{Endpoint}] Proxy disabled for this request.", endpointName); return handler; } if (!Uri.TryCreate(proxyUrl, UriKind.Absolute, out var proxyUri)) { logger.LogWarning( "[{Endpoint}] Proxy url invalid. raw={RawProxy}", endpointName, MaskProxyUrlForLog(proxyUrl) ); throw new ArgumentException("invalid proxy url, expected format like http://127.0.0.1:7890"); } handler.Proxy = new WebProxy(proxyUri); handler.UseProxy = true; return handler; } (string? ProxyUrl, string Source) ResolveProxyUrl(string? requestProxyUrl) { if (!string.IsNullOrWhiteSpace(requestProxyUrl)) { return (requestProxyUrl, "request.ProxyUrl"); } var envProxy = Environment.GetEnvironmentVariable("STEAM_PROXY_URL"); if (!string.IsNullOrWhiteSpace(envProxy)) { return (envProxy, "env.STEAM_PROXY_URL"); } return (null, "none"); } string MaskProxyUrlForLog(string? proxyUrl) { if (string.IsNullOrWhiteSpace(proxyUrl)) { return ""; } if (!Uri.TryCreate(proxyUrl, UriKind.Absolute, out var uri)) { var trimmed = proxyUrl.Trim(); if (trimmed.Length > 64) { trimmed = trimmed.Substring(0, 64) + "..."; } return $""; } var hostPort = uri.IsDefaultPort ? uri.Host : $"{uri.Host}:{uri.Port}"; return $"{uri.Scheme}://{hostPort}"; } record LoginRequest(string Username, string Password, string MaFilePath, string? ProxyUrl = null); record WalletBalanceRequest(string SteamLoginSecure, string? ProxyUrl = null); record TradeConfirmationsListRequest(string MaFilePath, SessionPayload Session, string? ProxyUrl = null); record TradeConfirmationOperateRequest( string MaFilePath, SessionPayload Session, ulong ConfirmationId, ulong? ConfirmationKey, string Operation, string? ProxyUrl = null ); record SessionPayload(ulong SteamId, string AccessToken, string RefreshToken, string? SessionId = null); internal class FileAuthenticator : IAuthenticator { private readonly SteamGuardAccount? account; private int deviceCodesGenerated = 0; public FileAuthenticator(SteamGuardAccount? account) { this.account = account; } public Task AcceptDeviceConfirmationAsync() { return Task.FromResult(false); } public async Task GetDeviceCodeAsync(bool previousCodeWasIncorrect) { if (previousCodeWasIncorrect) { if (deviceCodesGenerated > 2) throw new Exception("failed to generate device code from maFile"); await Task.Delay(30000); } if (account == null) return null; deviceCodesGenerated++; var code = await account.GenerateSteamGuardCodeAsync(); return code ?? ""; } public Task GetEmailCodeAsync(string email, bool previousCodeWasIncorrect) { // This implementation does not support interactive email codes. return Task.FromResult(string.Empty); } }