using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Net; using System.Security.Cryptography.X509Certificates; using System.Text.Json; using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Logging; using Supercell.Laser.Logic.Util; using Supercell.Laser.Server.Database; using Supercell.Laser.Server.Database.Models; using Supercell.Laser.Server.Networking.Session; namespace Supercell.Laser.Server.SCID { public static class SCIDStart { private static readonly object LogSync = new(); public static void Start() { Task.Run(async () => { try { var builder = WebApplication.CreateBuilder(); builder.Logging.SetMinimumLevel(LogLevel.Error); builder.WebHost.ConfigureKestrel(options => { options.ListenAnyIP(7787, listenOptions => { string certPath = "/etc/letsencrypt/live/weizyvpn.duckdns.org/fullchain.pem"; string keyPath = "/etc/letsencrypt/live/weizyvpn.duckdns.org/privkey.pem"; if (File.Exists(certPath) && File.Exists(keyPath)) { try { var cert = X509Certificate2.CreateFromPemFile(certPath, keyPath); listenOptions.UseHttps(cert); Log($"[SCID] Using direct HTTPS certificate from {certPath}"); } catch (Exception ex) { Log($"[SCID] Failed to load SSL certificate: {ex.Message}. Listening on HTTP."); } } else { Log("[SCID] Certificate not found at /etc/letsencrypt/live/weizyvpn.duckdns.org/. Listening on HTTP 7787 (ready for Nginx reverse proxy on 443)."); } }); }); var app = builder.Build(); app.Use(async (context, next) => { Stopwatch timer = Stopwatch.StartNew(); string method = context.Request.Method; string path = context.Request.Path.ToString(); if (method != "GET" || path.StartsWith("/api")) Log($"request {method} {path}"); try { await next(); if (method != "GET" || path.StartsWith("/api")) Log($"response {method} {path} status={context.Response.StatusCode} elapsedMs={timer.Elapsed.TotalMilliseconds:F1}"); } catch (Exception exception) { Log($"request {method} {path} failed after {timer.Elapsed.TotalMilliseconds:F1}ms: {exception}"); if (!context.Response.HasStarted) { context.Response.StatusCode = StatusCodes.Status200OK; await Results.Content( JsonSerializer.Serialize(new { ok = false, error = "server_error" }), "application/json; charset=utf-8").ExecuteAsync(context); } Log($"response {method} {path} status={context.Response.StatusCode} elapsedMs={timer.Elapsed.TotalMilliseconds:F1}"); } }); SCIDAssets.Register(app); app.MapGet("/health", () => Results.Json(new { ok = true, service = "scid", utc = DateTime.UtcNow })); RegisterRoutes(app); Log("API is running on port 7787 (domain: weizyvpn.duckdns.org)"); await app.RunAsync(); } catch (Exception exception) { Log($"Fatal error: {exception}"); } }); } internal static void Log(string message) { string line = $"[{DateTime.UtcNow:O}] [SCID] {message}"; Console.WriteLine(line); try { lock (LogSync) File.AppendAllText(Path.Combine(AppContext.BaseDirectory, "scid.log"), line + Environment.NewLine); } catch { } } private static void RegisterRoutes(WebApplication app) { app.MapMethods("/api/{**path}", new[] { "GET", "POST" }, async (HttpRequest request, string path) => { path = (path ?? string.Empty).Trim('/'); Log($"route {request.Method} /api/{path}"); Dictionary body = await ReadBody(request); Log($"route body keys={(body.Count == 0 ? "" : string.Join(",", body.Keys))}"); switch (path) { case "account/login": return await SCIDLogin(request, body); case "account/login.validate": return SCIDLoginValidate(body); case "account/login.confirm": return SCIDLoginConfirm(request, body); case "account/create": return await SCIDCreate(request, body); case "account/create.confirm": return SCIDCreateConfirm(request, body); case "account/tokenLogin.validate": return SCIDTokenLoginValidate(request, body); case "account/tokenLogin.confirm": return SCIDTokenLoginConfirm(request, body); default: return SCIDApi.Handle(request, path, body); } }); } private static async Task SCIDLogin(HttpRequest request, Dictionary body) { string identity = GetIdentity(body); if (identity == null) { Log("login rejected: missing email/phone"); return Fail("invalid_identity"); } string pin = SCIDSessions.Create(identity, false); Log($"[DEV] PIN для {identity}: {pin}"); _ = SCIDEmailSender.SendCode(identity, pin); return Ok(); } private static IResult SCIDLoginValidate(Dictionary body) { string identity = GetIdentity(body); if (identity == null || !SCIDSessions.Validate(identity, Get(body, "pin"), false)) return Fail("invalid_pin"); SCIDAccount account = SCIDBase.LoadByIdentity(identity); return Ok(new { isBound = true, system = (object)null }); } private static IResult SCIDLoginConfirm(HttpRequest request, Dictionary body) { string identity = GetIdentity(body); if (identity == null) return Fail("invalid_pin"); bool ok = SCIDSessions.IsValidated(identity, false) || SCIDSessions.Validate(identity, Get(body, "pin"), false); if (!ok) return Fail("invalid_pin"); SCIDAccount account = SCIDBase.LoadByIdentity(identity); bool created = account == null; Account gameAccount = GetGameAccount(request); if (created) account = CreateAccount(identity, gameAccount); if (account.GameAccountId == 0) { if (gameAccount != null) { account.GameAccountId = gameAccount.AccountId; account.GamePassToken = gameAccount.PassToken; SCIDBase.Save(account); } } if (created && gameAccount != null) AddRegistrationSkinNotification(gameAccount); Log($"login.confirm identity={identity} created={created} scidToken={account.ScidToken}"); SCIDSessions.Remove(identity); if (account.GameAccountId > 0) return Ok(new { scid = account.Scid, scidToken = account.ScidToken }); return Ok(new { scid = account.Scid, scidToken = account.ScidToken, bindToken = account.BindToken }); } private static async Task SCIDCreate(HttpRequest request, Dictionary body) { string identity = GetIdentity(body); if (identity == null) return Fail("invalid_identity"); if (SCIDBase.LoadByIdentity(identity) != null) return await SCIDLogin(request, body); string pin = SCIDSessions.Create(identity, true); Log($"[DEV] PIN для {identity}: {pin}"); _ = SCIDEmailSender.SendCode(identity, pin); return Ok(); } private static IResult SCIDCreateConfirm(HttpRequest request, Dictionary body) { string identity = GetIdentity(body); if (identity == null || !SCIDSessions.Validate(identity, Get(body, "pin"), true)) return Fail("invalid_pin"); SCIDAccount account = SCIDBase.LoadByIdentity(identity); if (account != null) return Fail("account_exists"); Account gameAccount = GetGameAccount(request); Log($"create.confirm gameAccount={gameAccount?.AccountId.ToString() ?? "null"}"); account = CreateAccount(identity, gameAccount); if (gameAccount != null) AddRegistrationSkinNotification(gameAccount); SCIDSessions.Remove(identity); if (account.GameAccountId > 0) return Ok(new { scid = account.Scid, scidToken = account.ScidToken }); return Ok(new { scid = account.Scid, scidToken = account.ScidToken, bindToken = account.BindToken }); } internal static void AddRegistrationSkinNotification(Account gameAccount) { try { if (gameAccount != null) { Accounts.Save(gameAccount); } } catch (Exception ex) { Log($"AddRegistrationSkinNotification warning: {ex.Message}"); } } private static IResult SCIDTokenLoginValidate(HttpRequest request, Dictionary body) { return SCIDAuthentication.FromRequest(request, body) == null ? Fail("invalid_token") : Ok(); } private static IResult SCIDTokenLoginConfirm(HttpRequest request, Dictionary body) { SCIDAccount account = SCIDAuthentication.FromRequest(request, body); if (account == null) return Fail("invalid_token"); return Ok(new { scid = account.Scid, scidToken = account.ScidToken }); } private static SCIDAccount CreateAccount(string identity, Account gameAccount = null) { SCIDAccount account = new SCIDAccount { Identity = identity, Scid = "scid_" + Guid.NewGuid().ToString("N"), BindToken = Guid.NewGuid().ToString("N"), GameAccountId = gameAccount?.AccountId ?? 1, GamePassToken = gameAccount?.PassToken }; account.ScidToken = CreateToken(account); SCIDBase.Create(account); return account; } private static string CreateToken(SCIDAccount account) { string header = WebEncoders.Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes(new { alg = "none" })); object claims = new { scid = account.Scid, game = "laser", env = "prod", pid = $"{account.GameAccountId.GetHigherInt()}-{account.GameAccountId.GetLowerInt()}" }; string payload = WebEncoders.Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes(claims)); return $"{header}.{payload}."; } private static Account GetGameAccount(HttpRequest request) { string forwardedAddress = request.Headers["X-Forwarded-For"].FirstOrDefault()?.Split(',')[0].Trim(); IPAddress remoteAddress = IPAddress.TryParse( forwardedAddress, out IPAddress forwardedIp) ? forwardedIp : request.HttpContext.Connection.RemoteIpAddress; if (remoteAddress == null) return null; Session session = Sessions.Find(s => s?.Connection?.Socket?.RemoteEndPoint is IPEndPoint ep && ep.Address.Equals(remoteAddress)); if (session != null && session.Home != null) { return Accounts.Load(session.Home.Avatar.AccountId); } return null; } private static string GetIdentity(Dictionary body) { string email = Get(body, "email")?.Trim().ToLowerInvariant(); if (!string.IsNullOrEmpty(email)) return "email:" + email; string phone = Get(body, "phone")?.Trim(); return string.IsNullOrEmpty(phone) ? null : "phone:" + phone; } private static string Get(Dictionary body, string key) { return body.TryGetValue(key, out string value) ? value : null; } private static async Task> ReadBody(HttpRequest request) { Dictionary result = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var parameter in request.Query) result[parameter.Key] = parameter.Value.ToString(); if (request.HasFormContentType) { var form = await request.ReadFormAsync(); foreach (var parameter in form) result[parameter.Key] = parameter.Value.ToString(); return result; } if (request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true) { try { using var document = await JsonDocument.ParseAsync(request.Body); if (document.RootElement.ValueKind == JsonValueKind.Object) { foreach (var property in document.RootElement.EnumerateObject()) { result[property.Name] = property.Value.ValueKind == JsonValueKind.String ? property.Value.GetString() ?? string.Empty : property.Value.GetRawText(); } } } catch (JsonException exception) { Log($"invalid JSON body: {exception.Message}"); } return result; } if (request.Body.CanRead) { using var reader = new StreamReader(request.Body); var rawBody = await reader.ReadToEndAsync(); var parameters = QueryHelpers.ParseQuery(rawBody); foreach (var parameter in parameters) result[parameter.Key] = parameter.Value.ToString(); } return result; } private static IResult Ok() => JsonBody(new { ok = true }); private static IResult Ok(object data) => JsonBody(new { ok = true, data }); private static IResult Fail(string error) => JsonBody(new { ok = false, error }); private static IResult JsonBody(object value) { return Results.Content(JsonSerializer.Serialize(value), "application/json; charset=utf-8"); } } }