namespace Aeshnidae.FairPlay; /// One login: which account, from which address, when. public sealed class LoginRecord { [JsonPropertyName("ts")] public string Timestamp { get; set; } = DateTime.UtcNow.ToString("O"); [JsonPropertyName("account")] public string Account { get; set; } = ""; [JsonPropertyName("char")] public string? Character { get; set; } [JsonPropertyName("ip")] public string Address { get; set; } = ""; } /// /// Login address history, and the correlations worth knowing about. /// /// Deliberately no third-party IP-reputation service: those cost money, need an /// outbound call on the login path, and answer a question ("is this a VPN?") that is /// less useful here than the one the data answers by itself - which accounts share an /// address, and which account is arriving from unusually many addresses. Someone /// running six clients through a VPN still shows up as six accounts on one address. /// /// Stored as JSONL next to the mod, same shape as the audit trail, so it greps the /// same way. /// internal sealed class IpRegistry : IDisposable { private readonly string _path; private readonly Settings _settings; private readonly ConcurrentQueue _pending = new(); /// account -> addresses seen, and address -> accounts seen. private readonly ConcurrentDictionary> _byAccount = new(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentDictionary> _byAddress = new(StringComparer.OrdinalIgnoreCase); private readonly object _writeLock = new(); private static readonly JsonSerializerOptions LineOptions = new() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, }; /// UTF-8 with no BOM - a BOM in front of the first record breaks strict JSON parsers. private static readonly Encoding Utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); public int KnownAccounts => _byAccount.Count; public int KnownAddresses => _byAddress.Count; public IpRegistry(string modPath, Settings settings) { _settings = settings; _path = Path.Combine(modPath, "logins.jsonl"); Load(); } /// Replays the history so correlations survive a restart. private void Load() { try { if (!File.Exists(_path)) return; var cutoff = _settings.HistoryDays > 0 ? DateTime.UtcNow.AddDays(-_settings.HistoryDays) : DateTime.MinValue; foreach (var line in File.ReadLines(_path)) { if (string.IsNullOrWhiteSpace(line)) continue; LoginRecord? rec; try { rec = JsonSerializer.Deserialize(line); } catch { continue; } if (rec is null || string.IsNullOrEmpty(rec.Account) || string.IsNullOrEmpty(rec.Address)) continue; if (DateTime.TryParse(rec.Timestamp, null, DateTimeStyles.RoundtripKind, out var when) && when < cutoff) continue; Index(rec.Account, rec.Address); } RebuildLinks(); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] could not read login history: {ex.Message}", ModManager.LogLevel.Warn); } } private void Index(string account, string address) { _byAccount.GetOrAdd(account, _ => new HashSet(StringComparer.OrdinalIgnoreCase)); _byAddress.GetOrAdd(address, _ => new HashSet(StringComparer.OrdinalIgnoreCase)); lock (_byAccount[account]) _byAccount[account].Add(address); lock (_byAddress[address]) _byAddress[address].Add(account); } /// /// Records a login and returns anything worth announcing. Called on the login /// path, so it must be cheap and must not throw. /// public List Record(string account, string? character, string address) { var notes = new List(); try { if (!_settings.RecordLoginAddresses || string.IsNullOrEmpty(account) || string.IsNullOrEmpty(address)) return notes; Index(account, address); RebuildLinks(); var rec = new LoginRecord { Account = account, Character = character, Address = address }; _pending.Enqueue(rec); Flush(); var accountsHere = Accounts(address); if (accountsHere.Count >= _settings.FlagAccountsPerAddress) notes.Add($"{address} has now been used by {accountsHere.Count} accounts: {string.Join(", ", accountsHere.Take(8))}"); var addressesUsed = Addresses(account); if (addressesUsed.Count >= _settings.FlagAddressesPerAccount) notes.Add($"account {account} has now logged in from {addressesUsed.Count} addresses"); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] login record failed: {ex.Message}", ModManager.LogLevel.Error); } return notes; } private void Flush() { lock (_writeLock) { var sb = new StringBuilder(); while (_pending.TryDequeue(out var rec)) sb.AppendLine(JsonSerializer.Serialize(rec, LineOptions)); if (sb.Length == 0) return; try { File.AppendAllText(_path, sb.ToString(), Utf8NoBom); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] could not append login history: {ex.Message}", ModManager.LogLevel.Error); } } } // ---------------------------------------------------------------- linking // // Live IP is a weak key: a second client behind a VPN gets a different address and // every rule stops seeing it as the same person. History does not evaporate, so // accounts that have EVER shared an address are treated as one household from then // on. Union-find over accounts, with addresses as the edges. private readonly Dictionary _parent = new(StringComparer.OrdinalIgnoreCase); private readonly object _linkLock = new(); private string Find(string account) { if (!_parent.TryGetValue(account, out var p)) { _parent[account] = account; return account; } if (!string.Equals(p, account, StringComparison.OrdinalIgnoreCase)) { var root = Find(p); _parent[account] = root; // path compression return root; } return account; } private void Union(string a, string b) { var ra = Find(a); var rb = Find(b); if (string.Equals(ra, rb, StringComparison.OrdinalIgnoreCase)) return; // Deterministic representative so the household key is stable across restarts. if (string.Compare(ra, rb, StringComparison.OrdinalIgnoreCase) <= 0) _parent[rb] = ra; else _parent[ra] = rb; } /// /// Rebuilds the account clusters from scratch. Addresses in ExemptAddresses do not /// create links - that is the whole point of the exemption, since a genuinely /// shared connection would otherwise weld strangers together permanently. Accounts /// in NeverLink stay in a household of one. /// public void RebuildLinks() { lock (_linkLock) { _parent.Clear(); if (!_settings.LinkAccountsBySharedAddress) return; foreach (var kv in _byAddress) { // Exempt addresses are declared shared; VPN exit nodes are shared by // construction. Neither is evidence that two accounts are one person. if (_settings.IsAddressExempt(kv.Key)) continue; if (_settings.ExcludeVpnFromLinking && Mod.IsVpnAddress(kv.Key)) continue; var accounts = Snapshot(kv.Value) .Where(a => !_settings.IsNeverLinked(a)) .ToList(); for (var i = 1; i < accounts.Count; i++) Union(accounts[0], accounts[i]); } } } /// Stable key for the household an account belongs to; the account itself if unlinked. public string HouseholdOf(string account) { if (string.IsNullOrEmpty(account) || !_settings.LinkAccountsBySharedAddress || _settings.IsNeverLinked(account)) return account ?? ""; lock (_linkLock) return Find(account); } /// Every account in the same household, including this one. public List Household(string account) { var key = HouseholdOf(account); lock (_linkLock) return _byAccount.Keys .Where(a => string.Equals(Find(a), key, StringComparison.OrdinalIgnoreCase)) .OrderBy(a => a, StringComparer.OrdinalIgnoreCase) .ToList(); } /// All households with more than one account, largest first. public List> Households(int minSize = 2) { lock (_linkLock) return _byAccount.Keys .GroupBy(a => Find(a), StringComparer.OrdinalIgnoreCase) .Where(g => g.Count() >= minSize) .Select(g => g.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList()) .OrderByDescending(g => g.Count) .ToList(); } public List Accounts(string address) => _byAddress.TryGetValue(address, out var set) ? Snapshot(set) : new List(); public List Addresses(string account) => _byAccount.TryGetValue(account, out var set) ? Snapshot(set) : new List(); private static List Snapshot(HashSet set) { lock (set) return set.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList(); } /// Addresses used by more than one account, worst first. Powers /fairplay shared. public List<(string Address, List Accounts)> Shared(int min = 2) => _byAddress .Select(kv => (Address: kv.Key, Accounts: Snapshot(kv.Value))) .Where(x => x.Accounts.Count >= min) .OrderByDescending(x => x.Accounts.Count) .ToList(); public void Dispose() => Flush(); }