Files
Vanadium-2023-2025-Leak-Server/Guard/Server/Bans/MachineRegistry.cs
T
skidfire fe3468c65d yikes,
2026-09-06 19:59:27 -04:00

327 lines
12 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using VanadiumGuard.Protocol;
namespace VanadiumGuard.Server.Bans
{
/// <summary>
/// The machine link table and the hardware ban list, held in memory and persisted to one
/// JSON file.
/// <para>
/// Two decisions are worth stating outright. A ban matches on the composite id or on any
/// single strong component, because someone who swaps a disk to get back in should not
/// thereby be a new person - but only strong components count, since matching on a CPU
/// model would ban everyone who bought the same processor. And a ban here refuses the
/// guard session, which is a different thing from banning an account: the account ban
/// belongs to the game's own moderation system, and this service does not reach into it.
/// </para>
/// <para>
/// A file is the right store at this scale and the wrong one past it. Everything goes
/// through this class precisely so the swap to a real database is one file to rewrite.
/// </para>
/// </summary>
public sealed class MachineRegistry
{
private static readonly JsonSerializerOptions Json = new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
private readonly ILogger<MachineRegistry> _logger;
private readonly string _path;
private readonly object _gate = new object();
private readonly Dictionary<string, MachineRecord> _byComposite =
new Dictionary<string, MachineRecord>(StringComparer.Ordinal);
public MachineRegistry(ILogger<MachineRegistry> logger, string banDirectory)
{
_logger = logger;
_path = Path.Combine(banDirectory, "machines.json");
Load();
}
/// <summary>
/// Records that a player opened a session from this machine, and returns the stored
/// record. Unusable fingerprints are ignored rather than stored under an empty key.
/// </summary>
public MachineRecord? Observe(MachineFingerprint? fingerprint, string playerId)
{
if (fingerprint == null || !fingerprint.IsUsable)
return null;
bool changed = false;
MachineRecord record;
lock (_gate)
{
if (!_byComposite.TryGetValue(fingerprint.Composite, out MachineRecord? existing))
{
existing = new MachineRecord
{
Composite = fingerprint.Composite,
Components = new Dictionary<string, string>(fingerprint.Components, StringComparer.Ordinal),
FirstSeenUtc = DateTimeOffset.UtcNow,
};
_byComposite[fingerprint.Composite] = existing;
changed = true;
}
else if (MergeComponents(existing, fingerprint))
{
changed = true;
}
existing.LastSeenUtc = DateTimeOffset.UtcNow;
if (!string.IsNullOrEmpty(playerId) && !existing.PlayerIds.Contains(playerId, StringComparer.Ordinal))
{
existing.PlayerIds.Add(playerId);
changed = true;
}
record = existing;
// LastSeen alone is not worth a write on every session open. Anything that
// changes what a moderator would see is.
if (changed)
Save();
}
return record;
}
/// <summary>
/// The active ban covering this machine, or null. Checks the exact machine first,
/// then every banned machine for a shared strong component.
/// </summary>
public (MachineRecord Record, MachineBan Ban)? FindActiveBan(MachineFingerprint? fingerprint)
{
if (fingerprint == null || !fingerprint.IsUsable)
return null;
DateTimeOffset now = DateTimeOffset.UtcNow;
lock (_gate)
{
if (_byComposite.TryGetValue(fingerprint.Composite, out MachineRecord? exact)
&& exact.Ban != null
&& exact.Ban.IsActive(now))
{
return (exact, exact.Ban);
}
foreach (MachineRecord record in _byComposite.Values)
{
if (record.Ban == null || !record.Ban.IsActive(now))
continue;
if (SharesStrongComponent(record, fingerprint))
return (record, record.Ban);
}
}
return null;
}
/// <summary>
/// Bans one machine by its composite id. Returns false when the machine has never
/// been seen: banning a hash nobody has reported would put a record in the list that
/// can never match anything and can never be reviewed.
/// </summary>
public bool Ban(string composite, string reason, string issuedBy, DateTimeOffset? expiresUtc)
{
if (string.IsNullOrWhiteSpace(composite))
return false;
lock (_gate)
{
if (!_byComposite.TryGetValue(composite, out MachineRecord? record))
return false;
record.Ban = new MachineBan
{
Reason = string.IsNullOrWhiteSpace(reason) ? "unspecified" : reason.Trim(),
IssuedBy = string.IsNullOrWhiteSpace(issuedBy) ? "unknown" : issuedBy.Trim(),
IssuedUtc = DateTimeOffset.UtcNow,
ExpiresUtc = expiresUtc,
LinkedPlayerIdsAtIssue = new List<string>(record.PlayerIds),
};
Save();
}
_logger.LogWarning(
"Machine {Composite} banned by {IssuedBy} until {Expires}: {Reason}",
composite, issuedBy, expiresUtc?.ToString("O") ?? "permanent", reason);
return true;
}
/// <summary>
/// Bans every machine this player has been seen on. This is how a moderator uses the
/// feature in practice: they know an account, not a hash.
/// </summary>
public IReadOnlyList<string> BanMachinesOf(string playerId, string reason, string issuedBy, DateTimeOffset? expiresUtc)
{
var banned = new List<string>();
foreach (MachineRecord record in ForPlayer(playerId))
{
if (Ban(record.Composite, reason, issuedBy, expiresUtc))
banned.Add(record.Composite);
}
if (banned.Count == 0)
_logger.LogInformation("No machines on record for player {Player}; nothing banned", playerId);
return banned;
}
/// <summary>Lifts a ban. The record stays; only the ban is removed.</summary>
public bool Unban(string composite, string liftedBy, string reason)
{
lock (_gate)
{
if (!_byComposite.TryGetValue(composite, out MachineRecord? record) || record.Ban == null)
return false;
record.Ban = null;
Save();
}
_logger.LogWarning("Machine {Composite} unbanned by {LiftedBy}: {Reason}", composite, liftedBy, reason);
return true;
}
public MachineRecord? Get(string composite)
{
lock (_gate)
{
return _byComposite.TryGetValue(composite, out MachineRecord? record) ? record : null;
}
}
public IReadOnlyList<MachineRecord> ForPlayer(string playerId)
{
if (string.IsNullOrEmpty(playerId))
return Array.Empty<MachineRecord>();
lock (_gate)
{
return _byComposite.Values
.Where(r => r.PlayerIds.Contains(playerId, StringComparer.Ordinal))
.ToList();
}
}
/// <summary>
/// True when the record and the fingerprint agree on a component specific enough to
/// mean the same machine.
/// </summary>
private static bool SharesStrongComponent(MachineRecord record, MachineFingerprint fingerprint)
{
foreach (string name in MachineComponents.Strong)
{
if (record.Components.TryGetValue(name, out string? stored)
&& fingerprint.Components.TryGetValue(name, out string? offered)
&& !string.IsNullOrEmpty(stored)
&& string.Equals(stored, offered, StringComparison.Ordinal))
{
return true;
}
}
return false;
}
/// <summary>
/// Folds newly reported components into a stored record. Components are added but
/// never overwritten: a machine that suddenly reports a different motherboard hash
/// under the same composite is either a hardware change or an attempt to overwrite
/// the thing a ban matches on, and keeping the first value read makes both harmless.
/// </summary>
private static bool MergeComponents(MachineRecord record, MachineFingerprint fingerprint)
{
bool changed = false;
foreach (KeyValuePair<string, string> pair in fingerprint.Components)
{
if (!record.Components.ContainsKey(pair.Key))
{
record.Components[pair.Key] = pair.Value;
changed = true;
}
}
return changed;
}
private void Load()
{
try
{
if (!File.Exists(_path))
{
_logger.LogInformation("No machine registry at {Path}; starting empty", _path);
return;
}
MachineRecord[]? records = JsonSerializer.Deserialize<MachineRecord[]>(
File.ReadAllText(_path), Json);
if (records == null)
return;
foreach (MachineRecord record in records)
{
if (!string.IsNullOrEmpty(record.Composite))
_byComposite[record.Composite] = record;
}
int banned = _byComposite.Values.Count(r => r.Ban != null);
_logger.LogInformation("Machine registry loaded: {Total} machines, {Banned} banned", _byComposite.Count, banned);
}
catch (Exception ex)
{
// Starting with an empty ban list would silently let every banned machine
// back in, so this is loud and the file is left alone for a human to look at.
_logger.LogError(ex, "Machine registry at {Path} could not be read; hardware bans are NOT in effect", _path);
}
}
/// <summary>
/// Writes the registry through a temporary file. Called with the lock held.
/// <para>
/// The rename matters more than it looks: this file is the only record of who is
/// banned, and a process killed midway through a plain overwrite would leave a
/// truncated file that fails to parse and unbans everyone at the next start.
/// </para>
/// </summary>
private void Save()
{
try
{
string? directory = Path.GetDirectoryName(_path);
if (!string.IsNullOrEmpty(directory))
Directory.CreateDirectory(directory);
string temporary = _path + ".tmp";
File.WriteAllText(temporary, JsonSerializer.Serialize(_byComposite.Values.ToArray(), Json));
File.Move(temporary, _path, overwrite: true);
}
catch (Exception ex)
{
_logger.LogError(ex, "Machine registry could not be written to {Path}", _path);
}
}
}
}