yikes,
This commit is contained in:
@@ -0,0 +1,376 @@
|
||||
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";
|
||||
|
||||
/// <summary>Shown to an account blocked because the machine it is on is banned.</summary>
|
||||
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<IActionResult> 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<IActionResult> 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
|
||||
{
|
||||
/// <summary>The machine to ban. Takes precedence over PlayerId when both are set.</summary>
|
||||
public string Hwid { get; set; }
|
||||
|
||||
/// <summary>Ban every machine this account has been seen on.</summary>
|
||||
public long? PlayerId { get; set; }
|
||||
|
||||
public string Reason { get; set; }
|
||||
|
||||
/// <summary>Omit or set to null for a permanent ban.</summary>
|
||||
public int? DurationDays { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bans a machine, and with it the accounts linked to that machine.
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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 });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<HwidDBClasses.HwidRecord>();
|
||||
|
||||
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
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user