M9 (first part): the means to locate a server

- IConnectedAddresses remembers which address each host's last connection reached, set in
  SafeHttpHandlerFactory's connect callback. That is once per pooled connection, with no
  second DNS lookup, and the address is never stored.
- IGeoLocator / DbIpLocator reads the offline DB-IP Lite city and ASN databases (MaxMind
  .mmdb, via MaxMind.Db). It maps memory, swaps to new files within ten minutes, rounds
  coordinates to one decimal, never looks up a private address, and answers nothing
  when the files are missing. CdnNetworks names the CDNs whose edge addresses say nothing
  about where a server is.
- deploy/max/geo-update.sh fetches this or last month's databases, checks them and swaps
  them in atomically. The privapub-geo timer runs it monthly as www-data, and setup.sh
  installs the directory, the script, the units and a first download.

Describing servers will use these in M8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-03 11:33:30 +02:00
1 parent fc15f6356d
commit cee85309b8
12 files changed
+357 -7

No files matched your search

+180
View File
@@ -0,0 +1,180 @@
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 const string CityFile = "dbip-city-lite.mmdb";
public const string AsnFile = "dbip-asn-lite.mmdb";
static readonly TimeSpan CheckInterval = TimeSpan.FromMinutes(10);
readonly IOptionsMonitor<StatisticsOptions> _options;
readonly ILogger<DbIpLocator> _logger;
readonly object _lock = new();
Reader _city;
Reader _asn;
DateTime _cityStamp;
DateTime _asnStamp;
DateTime _checkedAt = DateTime.MinValue;
bool _warned;
public DbIpLocator(IOptionsMonitor<StatisticsOptions> options, ILogger<DbIpLocator> 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();
Reader city, asn;
lock (_lock)
{
city = _city;
asn = _asn;
}
if (city == default && asn == default)
return default;
try
{
var place = city?.Find<Dictionary<string, object>>(address);
var network = asn?.Find<Dictionary<string, object>>(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;
}
}
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<string, object> Section(Dictionary<string, object> parent, string name) =>
parent?.GetValueOrDefault(name) as Dictionary<string, object>;
static string Text(Dictionary<string, object> 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();
}
}
public static class CdnNetworks
{
static readonly Dictionary<int, string> Known = new()
{
[13335] = "Cloudflare",
[209242] = "Cloudflare",
[54113] = "Fastly",
[20940] = "Akamai",
[16625] = "Akamai",
[16702] = "Akamai",
[21342] = "Akamai",
[200325] = "Bunny",
[60068] = "CDN77",
[15133] = "Edgio"
};
public static string Of(int? asn) => asn is { } number && Known.TryGetValue(number, out var name) ? name : default;
}
}