using Microsoft.AspNetCore.Mvc; using Vanadium.Auth; using Vanadium.Classes; using Vanadium.Classes.DBs; using Vanadium.Classes.DBs.DBClasses; using static Vanadium.Classes.DBs.DBClasses.PlayerDBClasses; using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net.Http; using System.Text; using System.Text.Json; using System.Threading.Tasks; namespace Vanadium.Controllers { [ApiController] public class AntiCheatController : ControllerBase { private static readonly HttpClient _http = new HttpClient(); private static readonly TimeSpan DedupWindow = TimeSpan.FromMinutes(10); private const string EvasionMessage = "cheating"; /// Shown to an account blocked because the machine it is on is banned. private const string HardwareBanMessage = "cheating"; public class ReportBody { public string Type { get; set; } public string Reason { get; set; } public string ModVersion { get; set; } public ulong SteamId { get; set; } public long? RoomId { get; set; } } [HttpPost("/api/anticheat/report")] public async Task Report([FromBody] ReportBody body) { var player = AuthStuff.GetCurrentPlayer(Request); if (player == null) return Unauthorized(""); if (body == null || string.IsNullOrWhiteSpace(body.Type) || string.IsNullOrWhiteSpace(body.Reason)) return BadRequest(new { error = "Missing type/reason." }); string type = body.Type.Trim().ToLower(); if (type != "cheat" && type != "injection") type = "other"; string reason = body.Reason.Length > 500 ? body.Reason.Substring(0, 500) : body.Reason; string username = player.Player?.Username ?? "Unknown"; /*ulong steamId = player.PlatformIds?.FirstOrDefault()?.PlatformId ?? body.SteamId; var detection = AntiCheatDB.Record(player.PlayerId, username, steamId, type, reason, body.ModVersion, body.RoomId, DedupWindow, out bool isNew); if (isNew) await RelayToDiscord(detection);*/ return Ok(new { success = true }); } public class HwidBody { public string Hwid { get; set; } } [HttpPost("/api/anticheat/hwid")] public async Task SubmitHwid([FromBody] HwidBody body) { var playerId = AuthStuff.GetPlayerId(Request); if (playerId == null) return Unauthorized(""); string hwid = body?.Hwid?.Trim(); if (string.IsNullOrEmpty(hwid) || hwid.Length < 16 || hwid.Length > 128) return BadRequest(new { error = "Invalid hwid." }); Bluetint.MarkProtected(playerId.Value); if (PlayerDB.IsBanned(playerId.Value)) return Ok(new { banned = true }); var bannedSiblings = HwidDB.LinkAndGetBannedSiblings(hwid, playerId.Value); // A hardware ban outranks the evasion check below: the machine itself is // blocked, so it does not matter whether any account on it is banned yet. if (HwidDB.IsHwidBanned(hwid)) { PlayerDB.ApplyBan(playerId.Value, HardwareBanMessage); var blocked = PlayerDB.GetCurrentPlayer(playerId.Value); var blockedDetails = blocked?.Player?.PlayerExtra?.ModerationBlockDetails; if (blockedDetails != null) await NotificationsController.SendBanned(playerId.Value, blockedDetails); Console.WriteLine($"[AntiCheat] Hardware ban hit: player {playerId.Value} on banned hwid"); return Ok(new { banned = true, message = HardwareBanMessage }); } if (bannedSiblings.Count > 0) { PlayerDB.ApplyBan(playerId.Value, EvasionMessage); var player = PlayerDB.GetCurrentPlayer(playerId.Value); var mbd = player?.Player?.PlayerExtra?.ModerationBlockDetails; if (mbd != null) await NotificationsController.SendBanned(playerId.Value, mbd); Console.WriteLine($"[AntiCheat] HWID evasion: player {playerId.Value} re-banned (banned siblings: {string.Join(",", bannedSiblings)})"); return Ok(new { banned = true, message = EvasionMessage }); } return Ok(new { banned = false }); } public class HwidBanBody { /// The machine to ban. Takes precedence over PlayerId when both are set. public string Hwid { get; set; } /// Ban every machine this account has been seen on. public long? PlayerId { get; set; } public string Reason { get; set; } /// Omit or set to null for a permanent ban. public int? DurationDays { get; set; } } /// /// Bans a machine, and with it the accounts linked to that machine. /// /// Moderator-gated rather than key-gated because this is the heaviest tool in the /// moderation set and every use of it needs a name attached. A shared machine puts /// unrelated people behind one hwid, so the response reports exactly which accounts /// were caught - the moderator finds out now, not from an appeal in a week. /// /// [HttpPost("/api/anticheat/hwid/ban")] public IActionResult BanHwid([FromBody] HwidBanBody body) { var moderator = RequireModerator(); if (moderator == null) return Unauthorized(""); if (body == null || string.IsNullOrWhiteSpace(body.Reason)) return BadRequest(new { error = "A reason is required." }); if (body.DurationDays is <= 0) return BadRequest(new { error = "DurationDays must be positive, or null for permanent." }); DateTime? expiresAt = body.DurationDays == null ? null : DateTime.UtcNow.AddDays(body.DurationDays.Value); string issuedBy = $"{moderator.Player?.Username ?? "moderator"} ({moderator.PlayerId})"; if (!string.IsNullOrWhiteSpace(body.Hwid)) { var bannedAccounts = HwidDB.BanHwid(body.Hwid.Trim(), body.Reason, issuedBy, expiresAt); if (bannedAccounts == null) return NotFound(new { error = "That machine has never checked in." }); return Ok(new { success = true, hwids = new[] { body.Hwid.Trim() }, bannedAccounts, expiresAt }); } if (body.PlayerId == null) return BadRequest(new { error = "Provide either Hwid or PlayerId." }); var (hwids, accounts) = HwidDB.BanMachinesOfPlayer(body.PlayerId.Value, body.Reason, issuedBy, expiresAt); if (hwids.Count == 0) return NotFound(new { error = "No machines on record for that player." }); return Ok(new { success = true, hwids, bannedAccounts = accounts, expiresAt }); } public class HwidUnbanBody { public string Hwid { get; set; } } /// /// Lifts a hardware ban. The accounts banned alongside it stay banned: some of them /// were banned on their own behaviour first, and reinstating those here would undo /// moderation nobody asked to undo. /// [HttpPost("/api/anticheat/hwid/unban")] public IActionResult UnbanHwid([FromBody] HwidUnbanBody body) { var moderator = RequireModerator(); if (moderator == null) return Unauthorized(""); if (string.IsNullOrWhiteSpace(body?.Hwid)) return BadRequest(new { error = "Hwid is required." }); string liftedBy = $"{moderator.Player?.Username ?? "moderator"} ({moderator.PlayerId})"; if (!HwidDB.UnbanHwid(body.Hwid.Trim(), liftedBy)) return NotFound(new { error = "No active hardware ban on that machine." }); return Ok(new { success = true }); } /// /// What a moderator looks at before deciding. Accepts either a machine or an account /// and returns the same shape, because the question is the same either way: who else /// is behind this. /// [HttpGet("/api/anticheat/hwid/lookup")] public IActionResult LookupHwid([FromQuery] string hwid, [FromQuery] long? playerId) { var moderator = RequireModerator(); if (moderator == null) return Unauthorized(""); var records = new List(); if (!string.IsNullOrWhiteSpace(hwid)) { var single = HwidDB.GetRecord(hwid.Trim()); if (single != null) records.Add(single); } else if (playerId != null) { records.AddRange(HwidDB.RecordsForPlayer(playerId.Value)); } else { return BadRequest(new { error = "Provide either hwid or playerId." }); } var now = DateTime.UtcNow; return Ok(records.Select(r => new { hwid = r.Hwid, players = r.PlayerIds.Select(id => new { playerId = id, username = PlayerDB.GetCurrentPlayer(id)?.Player?.Username, banned = PlayerDB.IsBanned(id) }), firstSeen = r.FirstSeen, lastSeen = r.LastSeen, banned = r.BanIsActive(now), banReason = r.BanReason, bannedBy = r.BannedBy, bannedAt = r.BannedAt, banExpiresAt = r.BanExpiresAt })); } /// /// The gate on every endpoint above. Returns null unless the caller is a moderator or /// a developer - a hardware ban is not something an ordinary authenticated client /// gets to issue against anyone. /// private FullPlayer RequireModerator() { var requester = AuthStuff.GetCurrentPlayer(Request); if (requester?.PlayerRoles == null) return null; bool allowed = requester.PlayerRoles.Contains(PlayerRoles.Moderator) || requester.PlayerRoles.Contains(PlayerRoles.Developer); return allowed ? requester : null; } public class CertificateBody { public string Key { get; set; } } [HttpPost("/bluetint/certificate/validate")] public IActionResult ValidateCertificate([FromBody] CertificateBody body) { string key = body?.Key; if (string.IsNullOrWhiteSpace(key)) return Unauthorized(""); try { string certPath = Path.Join(Environment.CurrentDirectory, ".BluetintConfig", "Certificate", "cert.pem"); if (!System.IO.File.Exists(certPath)) return Unauthorized(""); string pemText = System.IO.File.ReadAllText(certPath); using var privateRsa = System.Security.Cryptography.RSA.Create(); privateRsa.ImportFromPem(pemText.AsSpan()); using var submittedRsa = System.Security.Cryptography.RSA.Create(); string cleaned = key.Trim().Replace("\r", "").Replace("\n", "").Replace(" ", "+"); byte[] submittedKeyBytes = Convert.FromBase64String(cleaned); submittedRsa.ImportSubjectPublicKeyInfo(submittedKeyBytes, out _); byte[] expectedPublicKey = privateRsa.ExportSubjectPublicKeyInfo(); byte[] submittedPublicKey = submittedRsa.ExportSubjectPublicKeyInfo(); if (!expectedPublicKey.SequenceEqual(submittedPublicKey)) return Unauthorized(new { error = "Key mismatch", expected = Convert.ToBase64String(expectedPublicKey), submitted = Convert.ToBase64String(submittedPublicKey) }); return Ok(); } catch (Exception ex) { return Unauthorized(new { error = ex.Message }); } } [HttpGet("/bluetint/config/{config?}")] public IActionResult Rules(string? config) { if (string.IsNullOrEmpty(config)) return NotFound(""); string path = Path.Join(Environment.CurrentDirectory, ".BluetintConfig", $"{config}.json"); if (System.IO.File.Exists(path)) return Content(System.IO.File.ReadAllText(path), "application/json"); else return NotFound(""); } private static async Task RelayToDiscord(AntiCheatDBClasses.Detection d) { try { string title = d.Type == "cheat" ? "🚨 Cheater detected" : "💉 DLL injection detected"; int color = d.Type == "cheat" ? 0xE74C3C : 0xE67E22; var payload = new { username = "Bluetint Anti-Cheat", embeds = new[] { new { title, color, fields = new object[] { new { name = "Reason", value = string.IsNullOrEmpty(d.Reason) ? "n/a" : d.Reason, inline = false }, new { name = "Player", value = $"{d.Username} ({d.PlayerId})", inline = true }, new { name = "Steam ID", value = d.SteamId.ToString(), inline = true }, new { name = "Room", value = d.RoomId?.ToString() ?? "n/a", inline = true }, new { name = "Mod", value = string.IsNullOrEmpty(d.ModVersion) ? "n/a" : d.ModVersion, inline = true } }, timestamp = d.DetectedAt.ToString("o") } } }; var content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); await _http.PostAsync(ServerConfig.AntiCheatWebhook, content); } catch (Exception ex) { Console.WriteLine($"[AntiCheat] Discord relay failed: {ex.Message}"); } } } }