Program.cs 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659
  1. using System;
  2. using System.IO;
  3. using System.Linq;
  4. using System.Net;
  5. using System.Net.Http;
  6. using System.Threading.Tasks;
  7. using Microsoft.AspNetCore.Builder;
  8. using Microsoft.AspNetCore.Http;
  9. using Microsoft.Extensions.Logging;
  10. using Microsoft.Extensions.Hosting;
  11. using Newtonsoft.Json;
  12. using Newtonsoft.Json.Linq;
  13. using SteamAuth;
  14. using SteamKit2;
  15. using SteamKit2.Authentication;
  16. var builder = WebApplication.CreateBuilder(args);
  17. var app = builder.Build();
  18. app.MapPost("/login", async (HttpContext http) =>
  19. {
  20. var req = await GetRequestBodyAsync(http);
  21. if (req == null)
  22. {
  23. http.Response.StatusCode = 400;
  24. await http.Response.WriteAsync("invalid request");
  25. return;
  26. }
  27. if (!File.Exists(req.MaFilePath))
  28. {
  29. http.Response.StatusCode = 400;
  30. await http.Response.WriteAsync("maFile not found");
  31. return;
  32. }
  33. SteamGuardAccount? account;
  34. try
  35. {
  36. var contents = await File.ReadAllTextAsync(req.MaFilePath);
  37. account = JsonConvert.DeserializeObject<SteamGuardAccount>(contents);
  38. }
  39. catch (Exception ex)
  40. {
  41. http.Response.StatusCode = 500;
  42. await http.Response.WriteAsync("failed to read maFile: " + ex.Message);
  43. return;
  44. }
  45. try
  46. {
  47. // Start SteamClient and authenticate using the maFile-provided SteamGuardAccount
  48. SteamClient steamClient = CreateSteamClient(req.ProxyUrl, app.Logger, "login");
  49. steamClient.Connect();
  50. var timeoutMs = 15000;
  51. var waited = 0;
  52. while (!steamClient.IsConnected && waited < timeoutMs)
  53. {
  54. await Task.Delay(200);
  55. waited += 200;
  56. }
  57. if (!steamClient.IsConnected)
  58. {
  59. http.Response.StatusCode = 500;
  60. await http.Response.WriteAsync("failed to connect to steam network");
  61. return;
  62. }
  63. CredentialsAuthSession authSession = await steamClient.Authentication.BeginAuthSessionViaCredentialsAsync(new AuthSessionDetails
  64. {
  65. Username = req.Username,
  66. Password = req.Password,
  67. IsPersistentSession = false,
  68. // PlatformType and ClientOSType are optional; omit to avoid enum symbol differences across SteamKit2 versions.
  69. Authenticator = new FileAuthenticator(account),
  70. });
  71. var pollResponse = await authSession.PollingWaitForResultAsync();
  72. var sessionData = new SessionData()
  73. {
  74. SteamID = authSession.SteamID.ConvertToUInt64(),
  75. AccessToken = pollResponse.AccessToken,
  76. RefreshToken = pollResponse.RefreshToken,
  77. };
  78. // Serialize cookies
  79. var uri = new Uri("https://steamcommunity.com");
  80. var cookies = sessionData.GetCookies().GetCookies(uri).Cast<Cookie>().Select(c => new
  81. {
  82. name = c.Name,
  83. value = c.Value,
  84. domain = c.Domain,
  85. path = c.Path,
  86. expires = c.Expires == DateTime.MinValue ? null : (DateTime?)c.Expires
  87. }).ToArray();
  88. var resp = new
  89. {
  90. session = new
  91. {
  92. steamId = sessionData.SteamID,
  93. accessToken = sessionData.AccessToken,
  94. refreshToken = sessionData.RefreshToken,
  95. sessionId = sessionData.SessionID
  96. },
  97. cookies = cookies
  98. };
  99. http.Response.ContentType = "application/json";
  100. await http.Response.WriteAsync(JsonConvert.SerializeObject(resp, Formatting.Indented));
  101. return;
  102. }
  103. catch (Exception ex)
  104. {
  105. http.Response.StatusCode = 500;
  106. await http.Response.WriteAsync("login failed: " + ex.Message);
  107. return;
  108. }
  109. });
  110. app.MapPost("/wallet-balance", async (HttpContext http) =>
  111. {
  112. var req = await GetWalletBalanceRequestBodyAsync(http);
  113. if (req == null || string.IsNullOrWhiteSpace(req.SteamLoginSecure))
  114. {
  115. http.Response.StatusCode = 400;
  116. await http.Response.WriteAsync("invalid request");
  117. return;
  118. }
  119. try
  120. {
  121. var handler = CreateHttpClientHandler(req.ProxyUrl, app.Logger, "wallet-balance");
  122. using var client = new HttpClient(handler, disposeHandler: true)
  123. {
  124. Timeout = TimeSpan.FromSeconds(20)
  125. };
  126. var request = new HttpRequestMessage(HttpMethod.Get, "https://store.steampowered.com/api/getfundwalletinfo/?cc=us&l=english");
  127. request.Headers.Add("Cookie", $"steamLoginSecure={req.SteamLoginSecure}");
  128. var response = await client.SendAsync(request);
  129. var body = await response.Content.ReadAsStringAsync();
  130. if (!response.IsSuccessStatusCode)
  131. {
  132. http.Response.StatusCode = 500;
  133. await http.Response.WriteAsync($"wallet request failed: {(int)response.StatusCode}");
  134. return;
  135. }
  136. var json = JsonConvert.DeserializeObject<JObject>(body);
  137. if (json == null || json["success"]?.Value<int>() != 1 || json["user_wallet"] is not JObject wallet)
  138. {
  139. http.Response.StatusCode = 500;
  140. await http.Response.WriteAsync("wallet response invalid");
  141. return;
  142. }
  143. var resp = new
  144. {
  145. amount = wallet["amount"]?.Value<string>() ?? "0",
  146. currency = wallet["currency"]?.Value<string>() ?? "USD"
  147. };
  148. http.Response.ContentType = "application/json";
  149. await http.Response.WriteAsync(JsonConvert.SerializeObject(resp));
  150. }
  151. catch (Exception ex)
  152. {
  153. http.Response.StatusCode = 500;
  154. await http.Response.WriteAsync("wallet query failed: " + ex.Message);
  155. }
  156. });
  157. app.MapPost("/trade-confirmations/list", async (HttpContext http) =>
  158. {
  159. var req = await GetTradeConfirmationsListRequestBodyAsync(http);
  160. if (req == null || string.IsNullOrWhiteSpace(req.MaFilePath) || req.Session == null)
  161. {
  162. http.Response.StatusCode = 400;
  163. await http.Response.WriteAsync("invalid request");
  164. return;
  165. }
  166. if (!File.Exists(req.MaFilePath))
  167. {
  168. http.Response.StatusCode = 400;
  169. await http.Response.WriteAsync("maFile not found");
  170. return;
  171. }
  172. try
  173. {
  174. var (proxyUrl, source) = ResolveProxyUrl(req.ProxyUrl);
  175. app.Logger.LogInformation(
  176. "[{Endpoint}] Proxy resolved. source={Source}, value={Proxy}",
  177. "trade-confirmations/list",
  178. source,
  179. MaskProxyUrlForLog(proxyUrl)
  180. );
  181. app.Logger.LogInformation(
  182. "[{Endpoint}] Calling Steam confirmation list. mode={Mode}, proxy={Proxy}",
  183. "trade-confirmations/list",
  184. string.IsNullOrWhiteSpace(proxyUrl) ? "direct" : "proxy",
  185. MaskProxyUrlForLog(proxyUrl)
  186. );
  187. var account = await BuildAccountWithSessionAsync(req.MaFilePath, req.Session);
  188. if (account == null)
  189. {
  190. http.Response.StatusCode = 500;
  191. await http.Response.WriteAsync("failed to build steam account session");
  192. return;
  193. }
  194. if (account.Session.IsRefreshTokenExpired())
  195. {
  196. http.Response.StatusCode = 401;
  197. await http.Response.WriteAsync("session refresh token expired");
  198. return;
  199. }
  200. var accessTokenRefreshed = false;
  201. if (account.Session.IsAccessTokenExpired())
  202. {
  203. await account.Session.RefreshAccessToken();
  204. accessTokenRefreshed = true;
  205. }
  206. var confirmations = await account.FetchConfirmationsAsync(proxyUrl);
  207. var resp = new
  208. {
  209. session = new
  210. {
  211. steamId = account.Session.SteamID,
  212. accessToken = account.Session.AccessToken,
  213. refreshToken = account.Session.RefreshToken,
  214. sessionId = account.Session.SessionID,
  215. accessTokenRefreshed
  216. },
  217. confirmations = confirmations.Select(c => new
  218. {
  219. id = c.ID,
  220. key = c.Key,
  221. creatorId = c.Creator,
  222. type = c.ConfType.ToString(),
  223. headline = c.Headline,
  224. summary = c.Summary,
  225. accept = c.Accept,
  226. cancel = c.Cancel,
  227. icon = c.Icon
  228. }).ToArray()
  229. };
  230. http.Response.ContentType = "application/json";
  231. await http.Response.WriteAsync(JsonConvert.SerializeObject(resp));
  232. }
  233. catch (Exception ex)
  234. {
  235. http.Response.StatusCode = 500;
  236. await http.Response.WriteAsync("fetch confirmations failed: " + ex.Message);
  237. }
  238. });
  239. app.MapPost("/trade-confirmations/operate", async (HttpContext http) =>
  240. {
  241. var req = await GetTradeConfirmationOperateRequestBodyAsync(http);
  242. if (req == null
  243. || string.IsNullOrWhiteSpace(req.MaFilePath)
  244. || req.Session == null
  245. || req.ConfirmationId == 0
  246. || string.IsNullOrWhiteSpace(req.Operation))
  247. {
  248. http.Response.StatusCode = 400;
  249. await http.Response.WriteAsync("invalid request");
  250. return;
  251. }
  252. if (!File.Exists(req.MaFilePath))
  253. {
  254. http.Response.StatusCode = 400;
  255. await http.Response.WriteAsync("maFile not found");
  256. return;
  257. }
  258. var op = req.Operation.Trim().ToLowerInvariant();
  259. if (op != "accept" && op != "cancel")
  260. {
  261. http.Response.StatusCode = 400;
  262. await http.Response.WriteAsync("invalid operation, expected accept or cancel");
  263. return;
  264. }
  265. try
  266. {
  267. var (proxyUrl, source) = ResolveProxyUrl(req.ProxyUrl);
  268. app.Logger.LogInformation(
  269. "[{Endpoint}] Proxy resolved. source={Source}, value={Proxy}",
  270. "trade-confirmations/operate",
  271. source,
  272. MaskProxyUrlForLog(proxyUrl)
  273. );
  274. app.Logger.LogInformation(
  275. "[{Endpoint}] Calling Steam confirmation operation. op={Op}, mode={Mode}, proxy={Proxy}",
  276. "trade-confirmations/operate",
  277. op,
  278. string.IsNullOrWhiteSpace(proxyUrl) ? "direct" : "proxy",
  279. MaskProxyUrlForLog(proxyUrl)
  280. );
  281. var account = await BuildAccountWithSessionAsync(req.MaFilePath, req.Session);
  282. if (account == null)
  283. {
  284. http.Response.StatusCode = 500;
  285. await http.Response.WriteAsync("failed to build steam account session");
  286. return;
  287. }
  288. if (account.Session.IsRefreshTokenExpired())
  289. {
  290. http.Response.StatusCode = 401;
  291. await http.Response.WriteAsync("session refresh token expired");
  292. return;
  293. }
  294. var accessTokenRefreshed = false;
  295. if (account.Session.IsAccessTokenExpired())
  296. {
  297. await account.Session.RefreshAccessToken();
  298. accessTokenRefreshed = true;
  299. }
  300. ulong confirmationKey = req.ConfirmationKey ?? 0;
  301. ulong effectiveConfirmationId = req.ConfirmationId;
  302. var keyResolvedFromList = false;
  303. var idResolvedFromList = false;
  304. var matchedBy = "request";
  305. if (confirmationKey == 0)
  306. {
  307. var confirmations = await account.FetchConfirmationsAsync(proxyUrl);
  308. var matched = confirmations.FirstOrDefault(c => c.ID == req.ConfirmationId);
  309. if (matched != null)
  310. {
  311. matchedBy = "id";
  312. }
  313. else
  314. {
  315. // buylisting 返回的 confirmation_id 在部分场景为 creator_id,
  316. // 这里回退按 creator_id 匹配,便于调用方仅传一个确认订单号即可完成确认。
  317. matched = confirmations.FirstOrDefault(c => c.Creator == req.ConfirmationId);
  318. if (matched != null)
  319. {
  320. matchedBy = "creator_id";
  321. }
  322. }
  323. if (matched == null || matched.Key == 0)
  324. {
  325. http.Response.StatusCode = 404;
  326. await http.Response.WriteAsync("confirmation not found or key unavailable");
  327. return;
  328. }
  329. effectiveConfirmationId = matched.ID;
  330. confirmationKey = matched.Key;
  331. keyResolvedFromList = true;
  332. idResolvedFromList = true;
  333. }
  334. var confirmation = new Confirmation
  335. {
  336. ID = effectiveConfirmationId,
  337. Key = confirmationKey
  338. };
  339. var success = op == "accept"
  340. ? await account.AcceptConfirmation(confirmation, proxyUrl)
  341. : await account.DenyConfirmation(confirmation, proxyUrl);
  342. var resp = new
  343. {
  344. success,
  345. operation = op,
  346. requestedConfirmationId = req.ConfirmationId,
  347. confirmationId = effectiveConfirmationId,
  348. confirmationKey,
  349. keyResolvedFromList,
  350. idResolvedFromList,
  351. matchedBy,
  352. session = new
  353. {
  354. steamId = account.Session.SteamID,
  355. accessToken = account.Session.AccessToken,
  356. refreshToken = account.Session.RefreshToken,
  357. sessionId = account.Session.SessionID,
  358. accessTokenRefreshed
  359. }
  360. };
  361. http.Response.ContentType = "application/json";
  362. await http.Response.WriteAsync(JsonConvert.SerializeObject(resp));
  363. }
  364. catch (Exception ex)
  365. {
  366. http.Response.StatusCode = 500;
  367. await http.Response.WriteAsync("operate confirmation failed: " + ex.Message);
  368. }
  369. });
  370. app.Run();
  371. async Task<LoginRequest?> GetRequestBodyAsync(HttpContext http)
  372. {
  373. try
  374. {
  375. using var sr = new StreamReader(http.Request.Body);
  376. var body = await sr.ReadToEndAsync();
  377. if (string.IsNullOrWhiteSpace(body)) return null;
  378. return JsonConvert.DeserializeObject<LoginRequest>(body);
  379. }
  380. catch
  381. {
  382. return null;
  383. }
  384. }
  385. async Task<WalletBalanceRequest?> GetWalletBalanceRequestBodyAsync(HttpContext http)
  386. {
  387. try
  388. {
  389. using var sr = new StreamReader(http.Request.Body);
  390. var body = await sr.ReadToEndAsync();
  391. if (string.IsNullOrWhiteSpace(body)) return null;
  392. return JsonConvert.DeserializeObject<WalletBalanceRequest>(body);
  393. }
  394. catch
  395. {
  396. return null;
  397. }
  398. }
  399. async Task<TradeConfirmationsListRequest?> GetTradeConfirmationsListRequestBodyAsync(HttpContext http)
  400. {
  401. try
  402. {
  403. using var sr = new StreamReader(http.Request.Body);
  404. var body = await sr.ReadToEndAsync();
  405. if (string.IsNullOrWhiteSpace(body)) return null;
  406. return JsonConvert.DeserializeObject<TradeConfirmationsListRequest>(body);
  407. }
  408. catch
  409. {
  410. return null;
  411. }
  412. }
  413. async Task<TradeConfirmationOperateRequest?> GetTradeConfirmationOperateRequestBodyAsync(HttpContext http)
  414. {
  415. try
  416. {
  417. using var sr = new StreamReader(http.Request.Body);
  418. var body = await sr.ReadToEndAsync();
  419. if (string.IsNullOrWhiteSpace(body)) return null;
  420. return JsonConvert.DeserializeObject<TradeConfirmationOperateRequest>(body);
  421. }
  422. catch
  423. {
  424. return null;
  425. }
  426. }
  427. async Task<SteamGuardAccount?> BuildAccountWithSessionAsync(string maFilePath, SessionPayload sessionPayload)
  428. {
  429. var contents = await File.ReadAllTextAsync(maFilePath);
  430. var account = JsonConvert.DeserializeObject<SteamGuardAccount>(contents);
  431. if (account == null)
  432. {
  433. return null;
  434. }
  435. if (string.IsNullOrWhiteSpace(account.DeviceID))
  436. {
  437. throw new Exception("maFile missing device_id");
  438. }
  439. account.Session = new SessionData
  440. {
  441. SteamID = sessionPayload.SteamId,
  442. AccessToken = sessionPayload.AccessToken,
  443. RefreshToken = sessionPayload.RefreshToken,
  444. SessionID = sessionPayload.SessionId
  445. };
  446. return account;
  447. }
  448. SteamClient CreateSteamClient(string? requestProxyUrl, ILogger logger, string endpointName)
  449. {
  450. var (proxyUrl, source) = ResolveProxyUrl(requestProxyUrl);
  451. logger.LogInformation(
  452. "[{Endpoint}] Proxy resolved. source={Source}, value={Proxy}",
  453. endpointName,
  454. source,
  455. MaskProxyUrlForLog(proxyUrl)
  456. );
  457. if (string.IsNullOrWhiteSpace(proxyUrl))
  458. {
  459. logger.LogInformation("[{Endpoint}] Proxy disabled for this request.", endpointName);
  460. return new SteamClient();
  461. }
  462. if (!Uri.TryCreate(proxyUrl, UriKind.Absolute, out var proxyUri))
  463. {
  464. logger.LogWarning(
  465. "[{Endpoint}] Proxy url invalid. raw={RawProxy}",
  466. endpointName,
  467. MaskProxyUrlForLog(proxyUrl)
  468. );
  469. throw new ArgumentException("invalid proxy url, expected format like http://127.0.0.1:7890");
  470. }
  471. var config = SteamConfiguration.Create(builder =>
  472. {
  473. // Force WebSocket so proxy can be applied through HttpClient.
  474. builder.WithProtocolTypes(ProtocolTypes.WebSocket);
  475. builder.WithHttpClientFactory(() =>
  476. {
  477. var handler = new HttpClientHandler
  478. {
  479. Proxy = new WebProxy(proxyUri),
  480. UseProxy = true
  481. };
  482. return new HttpClient(handler, disposeHandler: true);
  483. });
  484. });
  485. return new SteamClient(config);
  486. }
  487. HttpClientHandler CreateHttpClientHandler(string? requestProxyUrl, ILogger logger, string endpointName)
  488. {
  489. var (proxyUrl, source) = ResolveProxyUrl(requestProxyUrl);
  490. logger.LogInformation(
  491. "[{Endpoint}] Proxy resolved. source={Source}, value={Proxy}",
  492. endpointName,
  493. source,
  494. MaskProxyUrlForLog(proxyUrl)
  495. );
  496. var handler = new HttpClientHandler();
  497. if (string.IsNullOrWhiteSpace(proxyUrl))
  498. {
  499. logger.LogInformation("[{Endpoint}] Proxy disabled for this request.", endpointName);
  500. return handler;
  501. }
  502. if (!Uri.TryCreate(proxyUrl, UriKind.Absolute, out var proxyUri))
  503. {
  504. logger.LogWarning(
  505. "[{Endpoint}] Proxy url invalid. raw={RawProxy}",
  506. endpointName,
  507. MaskProxyUrlForLog(proxyUrl)
  508. );
  509. throw new ArgumentException("invalid proxy url, expected format like http://127.0.0.1:7890");
  510. }
  511. handler.Proxy = new WebProxy(proxyUri);
  512. handler.UseProxy = true;
  513. return handler;
  514. }
  515. (string? ProxyUrl, string Source) ResolveProxyUrl(string? requestProxyUrl)
  516. {
  517. if (!string.IsNullOrWhiteSpace(requestProxyUrl))
  518. {
  519. return (requestProxyUrl, "request.ProxyUrl");
  520. }
  521. var envProxy = Environment.GetEnvironmentVariable("STEAM_PROXY_URL");
  522. if (!string.IsNullOrWhiteSpace(envProxy))
  523. {
  524. return (envProxy, "env.STEAM_PROXY_URL");
  525. }
  526. return (null, "none");
  527. }
  528. string MaskProxyUrlForLog(string? proxyUrl)
  529. {
  530. if (string.IsNullOrWhiteSpace(proxyUrl))
  531. {
  532. return "<none>";
  533. }
  534. if (!Uri.TryCreate(proxyUrl, UriKind.Absolute, out var uri))
  535. {
  536. var trimmed = proxyUrl.Trim();
  537. if (trimmed.Length > 64)
  538. {
  539. trimmed = trimmed.Substring(0, 64) + "...";
  540. }
  541. return $"<invalid:{trimmed}>";
  542. }
  543. var hostPort = uri.IsDefaultPort ? uri.Host : $"{uri.Host}:{uri.Port}";
  544. return $"{uri.Scheme}://{hostPort}";
  545. }
  546. record LoginRequest(string Username, string Password, string MaFilePath, string? ProxyUrl = null);
  547. record WalletBalanceRequest(string SteamLoginSecure, string? ProxyUrl = null);
  548. record TradeConfirmationsListRequest(string MaFilePath, SessionPayload Session, string? ProxyUrl = null);
  549. record TradeConfirmationOperateRequest(
  550. string MaFilePath,
  551. SessionPayload Session,
  552. ulong ConfirmationId,
  553. ulong? ConfirmationKey,
  554. string Operation,
  555. string? ProxyUrl = null
  556. );
  557. record SessionPayload(ulong SteamId, string AccessToken, string RefreshToken, string? SessionId = null);
  558. internal class FileAuthenticator : IAuthenticator
  559. {
  560. private readonly SteamGuardAccount? account;
  561. private int deviceCodesGenerated = 0;
  562. public FileAuthenticator(SteamGuardAccount? account)
  563. {
  564. this.account = account;
  565. }
  566. public Task<bool> AcceptDeviceConfirmationAsync()
  567. {
  568. return Task.FromResult(false);
  569. }
  570. public async Task<string> GetDeviceCodeAsync(bool previousCodeWasIncorrect)
  571. {
  572. if (previousCodeWasIncorrect)
  573. {
  574. if (deviceCodesGenerated > 2)
  575. throw new Exception("failed to generate device code from maFile");
  576. await Task.Delay(30000);
  577. }
  578. if (account == null) return null;
  579. deviceCodesGenerated++;
  580. var code = await account.GenerateSteamGuardCodeAsync();
  581. return code ?? "";
  582. }
  583. public Task<string> GetEmailCodeAsync(string email, bool previousCodeWasIncorrect)
  584. {
  585. // This implementation does not support interactive email codes.
  586. return Task.FromResult(string.Empty);
  587. }
  588. }