using MaxMind.Db; using Microsoft.Extensions.Options; using PrivaPub.Infrastructure.Http; using PrivaPub.Infrastructure.Statistics; using System.Net; namespace PrivaPub.Infrastructure.Geo { public sealed record GeoFix(string Country, string City, double? Latitude, double? Longitude, int? Asn, string AsnOrg); public interface IGeoLocator { GeoFix Locate(IPAddress address); string Source { get; } } public sealed class NoGeoLocator : IGeoLocator { public GeoFix Locate(IPAddress address) => default; public string Source => default; } public sealed class DbIpLocator : IGeoLocator, IDisposable { public static readonly string CityFile = GeoUpdater.FileOf("city"); public static readonly string AsnFile = GeoUpdater.FileOf("asn"); static readonly TimeSpan CheckInterval = TimeSpan.FromMinutes(10); readonly IOptionsMonitor _options; readonly ILogger _logger; readonly object _lock = new(); Reader _city; Reader _asn; DateTime _cityStamp; DateTime _asnStamp; DateTime _checkedAt = DateTime.MinValue; bool _warned; public DbIpLocator(IOptionsMonitor options, ILogger logger) { _options = options; _logger = logger; } public string Source { get { Refresh(); var built = _city?.Metadata.BuildDate ?? _asn?.Metadata.BuildDate; return built == default ? default : $"DB-IP Lite {built.Value:yyyy-MM}"; } } public GeoFix Locate(IPAddress address) { if (address == default || !IpRangeGuard.IsPublic(address)) return default; Refresh(); Dictionary place, network; try { // under the lock, so a reload cannot dispose a reader in the middle of a lookup; lookups are rare lock (_lock) { if (_city == default && _asn == default) return default; place = _city?.Find>(address); network = _asn?.Find>(address); } var location = Section(place, "location"); return new GeoFix( Text(Section(place, "country"), "iso_code"), Text(Section(Section(place, "city"), "names"), "en"), Round(location?.GetValueOrDefault("latitude")), Round(location?.GetValueOrDefault("longitude")), Number(network?.GetValueOrDefault("autonomous_system_number")), network?.GetValueOrDefault("autonomous_system_organization") as string); } catch (Exception ex) when (ex is InvalidDatabaseException or ArgumentException) { _logger.LogWarning(ex, "The geolocation databases could not be read"); return default; } } // Looks at the files again now instead of within the next ten minutes: GeoUpdater has just replaced one. public void Reload() { lock (_lock) _checkedAt = DateTime.MinValue; Refresh(); } void Refresh() { if (DateTime.UtcNow - _checkedAt < CheckInterval) return; lock (_lock) { if (DateTime.UtcNow - _checkedAt < CheckInterval) return; _checkedAt = DateTime.UtcNow; var directory = _options.CurrentValue.GeoDirectory; (_city, _cityStamp) = Open(Path.Combine(directory ?? string.Empty, CityFile), _city, _cityStamp); (_asn, _asnStamp) = Open(Path.Combine(directory ?? string.Empty, AsnFile), _asn, _asnStamp); if (_city == default && _asn == default && !_warned) { _warned = true; _logger.LogInformation("No geolocation databases in {Directory}; servers are not located", directory); } } } (Reader, DateTime) Open(string path, Reader current, DateTime stamp) { if (!File.Exists(path)) { current?.Dispose(); return (default, default); } var modified = File.GetLastWriteTimeUtc(path); if (current != default && modified == stamp) return (current, stamp); try { var reader = new Reader(path, FileAccessMode.MemoryMapped); current?.Dispose(); return (reader, modified); } catch (Exception ex) when (ex is InvalidDatabaseException or IOException) { _logger.LogWarning(ex, "{Path} is not a readable geolocation database", path); return (current, stamp); } } static Dictionary Section(Dictionary parent, string name) => parent?.GetValueOrDefault(name) as Dictionary; static string Text(Dictionary parent, string name) => parent?.GetValueOrDefault(name) as string; static double? Round(object value) => value switch { double d => Math.Round(d, 1), float f => Math.Round(f, 1), _ => (double?)null }; static int? Number(object value) => value switch { long l => (int)l, int i => i, uint u => (int)u, ulong ul => (int)ul, _ => (int?)null }; public void Dispose() { _city?.Dispose(); _asn?.Dispose(); } } }