namespace Aeshnidae.FairPlay;
///
/// Local VPN / datacentre address matching.
///
/// Deliberately a downloaded list matched in-process rather than an API lookup. A
/// per-login API call puts a third party on the login path, needs a key, and fails
/// silently when a quota runs out - all to answer a question that a text file of CIDR
/// ranges answers just as well, offline, for nothing.
///
/// The lists come from X4BNet/lists_vpn (VPN and datacentre ranges, rebuilt daily) via
/// the refresh-vpn-lists.sh cron job; any file of one-CIDR-per-line works.
///
/// IPv4 only for now. AC clients connect over IPv4 and every address seen on this
/// server so far is v4; a v6 login simply never matches and is treated as not-VPN,
/// which fails in the permissive direction.
///
internal sealed class VpnList
{
/// prefix length -> the set of network addresses at that length.
private readonly Dictionary> _byPrefix = new();
public int Ranges { get; private set; }
public DateTime LoadedUtc { get; private set; }
public string Source { get; private set; } = "";
public static VpnList Load(string directory)
{
var list = new VpnList { Source = directory };
try
{
if (!Directory.Exists(directory))
return list;
foreach (var file in Directory.GetFiles(directory, "*.txt"))
{
foreach (var raw in File.ReadLines(file))
{
var line = raw.Trim();
if (line.Length == 0 || line[0] == '#' || line[0] == ';')
continue;
list.Add(line);
}
}
list.LoadedUtc = DateTime.UtcNow;
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] could not load VPN lists from {directory}: {ex.Message}",
ModManager.LogLevel.Warn);
}
return list;
}
private void Add(string cidr)
{
// Accept both "1.2.3.0/24" and a bare "1.2.3.4".
var slash = cidr.IndexOf('/');
var addressPart = slash < 0 ? cidr : cidr[..slash];
var prefix = 32;
if (slash >= 0 && (!int.TryParse(cidr[(slash + 1)..], out prefix) || prefix is < 0 or > 32))
return;
if (!TryToUInt(addressPart, out var address))
return;
var mask = prefix == 0 ? 0u : uint.MaxValue << (32 - prefix);
if (!_byPrefix.TryGetValue(prefix, out var set))
_byPrefix[prefix] = set = new HashSet();
if (set.Add(address & mask))
Ranges++;
}
///
/// Is this address inside any listed range?
///
/// At most 33 hash lookups - one per distinct prefix length present - rather than a
/// scan of every range, which matters because the combined lists run to six figures.
///
public bool Contains(string? ip)
{
if (string.IsNullOrEmpty(ip) || Ranges == 0 || !TryToUInt(ip, out var address))
return false;
foreach (var (prefix, set) in _byPrefix)
{
var mask = prefix == 0 ? 0u : uint.MaxValue << (32 - prefix);
if (set.Contains(address & mask))
return true;
}
return false;
}
private static bool TryToUInt(string ip, out uint value)
{
value = 0;
if (!IPAddress.TryParse(ip, out var parsed) || parsed.AddressFamily != AddressFamily.InterNetwork)
return false;
var bytes = parsed.GetAddressBytes();
value = ((uint)bytes[0] << 24) | ((uint)bytes[1] << 16) | ((uint)bytes[2] << 8) | bytes[3];
return true;
}
}