PrivaPub now finds CDNs three ways, best first: the address ranges the CDNs publish (Cloudflare, Fastly, Amazon CloudFront, Bunny, Gcore, Imperva), downloaded daily by CdnUpdater and kept in CdnRangeSet; the CDN's fingerprint in the responses it already gets from a server (EdgeHintsHandler on the federation client); and the networks that carry only a CDN. The fixed ASN list is gone; ASNs shared with plain hosting (AWS, DataPacket) no longer hide a server. A server's Geo records the CDN, its domain and how it was found, and weekly snapshots now keep the city and coordinates too. Servers through time (ServerPlaces): /instances/:host/history lists a server's weekly snapshots, a CDN-fronted server's geo names the CDN's domain and where the server was before it (before_cdn), and /api/privapub/v1/cdns and /cdns/:domain group servers by CDN with week by week who joined and who left. Owner decisions recorded in ROADMAP. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
292 lines
12 KiB
C#
292 lines
12 KiB
C#
using MongoDB.Entities;
|
|
|
|
using PrivaPub.Models.Jobs;
|
|
using PrivaPub.Tests.Support;
|
|
using PrivaPub.Tests.Support.Host;
|
|
|
|
using System.Net;
|
|
using System.Text.Json.Nodes;
|
|
|
|
namespace PrivaPub.Tests.Http
|
|
{
|
|
// Where a server is, on the public instance API: where it was located (city, coordinates and network, whatever its size),
|
|
// only the CDN's name for a CDN-fronted one, the batch form, and this server itself.
|
|
[Trait("Category", "Integration")]
|
|
public sealed class InstanceLocationTests : IAsyncLifetime
|
|
{
|
|
PrivaPubHost _host;
|
|
|
|
public async ValueTask InitializeAsync()
|
|
{
|
|
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
|
_host = await PrivaPubHost.Shared();
|
|
}
|
|
|
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
|
|
|
static CancellationToken Token => TestContext.Current.CancellationToken;
|
|
|
|
static async Task<string> Instance(int users, InstanceGeo geo, string name = "located")
|
|
{
|
|
var host = $"{name}{Guid.NewGuid():N}.example";
|
|
await DB.Default.SaveAsync(new RemoteInstance { Host = host, Software = "mastodon", UsersTotal = users, Geo = geo }, Token);
|
|
return host;
|
|
}
|
|
|
|
static InstanceGeo Milan(string cdn = default) => new()
|
|
{
|
|
Country = "IT",
|
|
City = "Milan",
|
|
Latitude = 45.5,
|
|
Longitude = 9.2,
|
|
Asn = 12345,
|
|
AsnOrg = "Example Hosting",
|
|
Cdn = cdn,
|
|
Ipv6 = true,
|
|
Source = "DB-IP Lite 2026-10",
|
|
LocatedAt = DateTime.UtcNow.AddDays(-2)
|
|
};
|
|
|
|
async Task<JsonNode> Geo(string host) => (await _host.Client().Get($"/api/privapub/v1/instances/{host}")).Ok().Body["geo"];
|
|
|
|
[Fact]
|
|
public async Task A_located_server_shows_its_city_coordinates_and_network()
|
|
{
|
|
var geo = await Geo(await Instance(50, Milan()));
|
|
|
|
Assert.Equal("city", geo.Text("precision"));
|
|
Assert.Equal(("IT", "Milan", "Example Hosting"), (geo.Text("country"), geo.Text("city"), geo.Text("network")));
|
|
Assert.Equal(45.5, geo["latitude"]!.GetValue<double>());
|
|
Assert.Equal(9.2, geo["longitude"]!.GetValue<double>());
|
|
Assert.Equal("DB-IP Lite 2026-10", geo.Text("source"));
|
|
Assert.NotNull(geo.Text("located_at"));
|
|
Assert.Null(geo["asn"]);
|
|
Assert.Null(geo["ipv6"]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_one_user_server_shows_its_city_too()
|
|
{
|
|
var geo = await Geo(await Instance(1, Milan()));
|
|
|
|
Assert.Equal("city", geo.Text("precision"));
|
|
Assert.Equal(("IT", "Milan", "Example Hosting"), (geo.Text("country"), geo.Text("city"), geo.Text("network")));
|
|
Assert.Equal(45.5, geo["latitude"]!.GetValue<double>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_server_located_only_to_its_country_shows_the_country()
|
|
{
|
|
var geo = await Geo(await Instance(50, new InstanceGeo { Country = "IT", Source = "DB-IP Lite 2026-10", LocatedAt = DateTime.UtcNow }));
|
|
|
|
Assert.Equal("country", geo.Text("precision"));
|
|
Assert.Equal("IT", geo.Text("country"));
|
|
Assert.Null(geo["city"]);
|
|
Assert.Null(geo["latitude"]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_cdn_fronted_server_shows_only_its_cdn()
|
|
{
|
|
var geo = await Geo(await Instance(5000, Milan(cdn: "Cloudflare")));
|
|
|
|
Assert.Equal("cdn", geo.Text("precision"));
|
|
Assert.Equal("Cloudflare", geo.Text("cdn"));
|
|
Assert.Null(geo["country"]);
|
|
Assert.Null(geo["latitude"]);
|
|
Assert.Null(geo["network"]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_unlocated_server_has_no_place()
|
|
{
|
|
Assert.Null(await Geo(await Instance(50, default)));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task The_provenance_of_a_post_carries_the_same_projection()
|
|
{
|
|
var host = await Instance(3, Milan());
|
|
var described = (await _host.Client().Get($"/api/privapub/v1/instances/{host}")).Ok().Body;
|
|
|
|
Assert.Equal("city", described["geo"].Text("precision"));
|
|
Assert.Equal(described["geo"]!.ToJsonString(), (await Geo(host))!.ToJsonString());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Several_servers_answer_at_once_in_the_order_asked_without_the_unknown_ones()
|
|
{
|
|
var big = await Instance(50, Milan(), "big");
|
|
var small = await Instance(3, Milan(), "small");
|
|
var unknown = $"unknown{Guid.NewGuid():N}.example";
|
|
|
|
var answer = (await _host.Client().Get($"/api/privapub/v1/instances?host[]={small}&host[]={unknown}&host[]={big.ToUpperInvariant()}&host[]={small}")).Ok();
|
|
var hosts = answer.Body!.AsArray().Select(i => i.Text("host")).ToList();
|
|
|
|
Assert.Equal(new[] { small, big }, hosts);
|
|
Assert.Equal("city", answer.Body![0]["geo"].Text("precision"));
|
|
Assert.Equal("city", answer.Body![1]["geo"].Text("precision"));
|
|
Assert.Empty((await _host.Client().Get("/api/privapub/v1/instances")).Ok().Body!.AsArray());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task At_most_forty_servers_answer_at_once()
|
|
{
|
|
var hosts = new List<string>();
|
|
for (var i = 0; i < 42; i++)
|
|
hosts.Add(await Instance(1, default, "many"));
|
|
|
|
var answer = (await _host.Client().Get("/api/privapub/v1/instances?" + string.Join("&", hosts.Select(h => "host[]=" + h)))).Ok();
|
|
|
|
Assert.Equal(hosts.Take(40), answer.Body!.AsArray().Select(i => i.Text("host")));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task This_server_describes_itself()
|
|
{
|
|
var self = (await _host.Client().Get($"/api/privapub/v1/instances/{PrivaPubHost.Host}")).Ok().Body;
|
|
|
|
Assert.Equal(PrivaPubHost.Host, self.Text("host"));
|
|
Assert.Equal("privapub", self.Text("software"));
|
|
Assert.Equal("activitypub", Assert.Single(self["protocols"]!.AsArray())!.GetValue<string>());
|
|
Assert.Null(self["geo"]);
|
|
var batch = (await _host.Client().Get($"/api/privapub/v1/instances?host[]={PrivaPubHost.Host}")).Ok().Body!.AsArray();
|
|
Assert.Equal("privapub", Assert.Single(batch).Text("software"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task The_owner_may_place_this_server()
|
|
{
|
|
var placed = await SelfPlacedHost.Shared();
|
|
|
|
var geo = (await placed.Client().Get($"/api/privapub/v1/instances/{PrivaPubHost.Host}")).Ok().Body["geo"];
|
|
|
|
Assert.Equal("city", geo.Text("precision"));
|
|
Assert.Equal(("IT", "Milan", "configured"), (geo.Text("country"), geo.Text("city"), geo.Text("source")));
|
|
Assert.Equal(45.5, geo["latitude"]!.GetValue<double>());
|
|
Assert.Equal(9.2, geo["longitude"]!.GetValue<double>());
|
|
}
|
|
|
|
static string WeeksAgo(int weeks) => PrivaPub.Federation.Objects.InstanceDescriber.Week(DateTime.UtcNow.AddDays(-7 * weeks));
|
|
|
|
static Task Snapshot(string host, int weeksAgo, string cdn = default, string cdnDomain = default, string city = default, double? latitude = default,
|
|
double? longitude = default, string country = default) =>
|
|
DB.Default.SaveAsync(new RemoteInstanceSnapshot
|
|
{
|
|
Host = host, Week = WeeksAgo(weeksAgo), TakenAt = DateTime.UtcNow.AddDays(-7 * weeksAgo), Reachable = true, Software = "mastodon", UsersTotal = 3,
|
|
Cdn = cdn, CdnDomain = cdnDomain, CdnSource = cdn == default ? default : "ranges", Country = country, City = city, Latitude = latitude, Longitude = longitude
|
|
}, Token);
|
|
|
|
[Fact]
|
|
public async Task A_cdn_fronted_server_names_its_cdn_and_where_it_was_before_it()
|
|
{
|
|
var host = await Instance(3, new InstanceGeo
|
|
{
|
|
Country = "US", City = "San Francisco", Latitude = 37.8, Longitude = -122.4, Cdn = "Cloudflare", CdnDomain = "cloudflare.com", CdnSource = "ranges",
|
|
Source = "DB-IP Lite 2026-10", LocatedAt = DateTime.UtcNow
|
|
}, "fronted");
|
|
await Snapshot(host, 3, city: "Berlin", latitude: 52.5, longitude: 13.4, country: "DE");
|
|
await Snapshot(host, 2, city: "Hamburg", latitude: 53.6, longitude: 10.0, country: "DE");
|
|
await Snapshot(host, 1, cdn: "Cloudflare", cdnDomain: "cloudflare.com", city: "San Francisco", latitude: 37.8, longitude: -122.4, country: "US");
|
|
|
|
var geo = await Geo(host);
|
|
|
|
Assert.Equal(("cdn", "Cloudflare", "cloudflare.com", "ranges"), (geo.Text("precision"), geo.Text("cdn"), geo.Text("cdn_domain"), geo.Text("cdn_source")));
|
|
Assert.Null(geo["latitude"]);
|
|
Assert.Equal((WeeksAgo(2), "Hamburg", "DE"), (geo["before_cdn"].Text("week"), geo["before_cdn"].Text("city"), geo["before_cdn"].Text("country")));
|
|
Assert.Equal(53.6, geo["before_cdn"]!["latitude"]!.GetValue<double>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_server_history_is_weekly_newest_first_and_a_cdn_hides_its_place()
|
|
{
|
|
var host = await Instance(3, Milan(), "historic");
|
|
await Snapshot(host, 2, city: "Milan", latitude: 45.5, longitude: 9.2, country: "IT");
|
|
await Snapshot(host, 1, cdn: "Fastly", cdnDomain: "fastly.com", city: "Frankfurt", latitude: 50.1, longitude: 8.7, country: "DE");
|
|
|
|
var weeks = (await _host.Client().Get($"/api/privapub/v1/instances/{host}/history")).Ok().Body!.AsArray();
|
|
|
|
Assert.Equal(new[] { WeeksAgo(1), WeeksAgo(2) }, weeks.Select(w => w.Text("week")));
|
|
Assert.Equal(("cdn", "fastly.com"), (weeks[0]!["geo"].Text("precision"), weeks[0]!["geo"].Text("cdn_domain")));
|
|
Assert.Null(weeks[0]!["geo"]!["city"]);
|
|
Assert.Equal(("city", "Milan"), (weeks[1]!["geo"].Text("precision"), weeks[1]!["geo"].Text("city")));
|
|
Assert.Equal(3, weeks[1]!["users_total"]!.GetValue<long>());
|
|
Assert.Single((await _host.Client().Get($"/api/privapub/v1/instances/{host}/history?weeks=1")).Ok().Body!.AsArray());
|
|
Assert.Equal(HttpStatusCode.NotFound, (await _host.Client().Get($"/api/privapub/v1/instances/unknown{Guid.NewGuid():N}.example/history")).Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Servers_are_grouped_by_their_cdn_week_by_week()
|
|
{
|
|
var cdn = $"Edge{Guid.NewGuid():N}"[..20];
|
|
var key = cdn.ToLowerInvariant();
|
|
var stayed = await Instance(3, new InstanceGeo { Country = "FR", Cdn = cdn, CdnSource = "headers" }, "stayed");
|
|
var moved = await Instance(3, Milan(), "moved");
|
|
await Snapshot(moved, 3, cdn: cdn);
|
|
await Snapshot(moved, 2, cdn: cdn);
|
|
await Snapshot(stayed, 2, cdn: cdn);
|
|
await Snapshot(moved, 1, city: "Milan", latitude: 45.5, longitude: 9.2, country: "IT");
|
|
await Snapshot(stayed, 1, cdn: cdn);
|
|
|
|
var list = (await _host.Client().Get("/api/privapub/v1/cdns")).Ok().Body!.AsArray();
|
|
var group = list.Single(g => g.Text("key") == key);
|
|
Assert.Equal((cdn, 1), (group.Text("name"), group["servers"]!.GetValue<int>()));
|
|
var cloudflare = list.Single(g => g.Text("key") == "cloudflare.com");
|
|
Assert.Equal(new[] { "ranges", "headers", "asn" }, cloudflare["detected_by"]!.AsArray().Select(v => v!.GetValue<string>()));
|
|
|
|
var detail = (await _host.Client().Get($"/api/privapub/v1/cdns/{key}")).Ok().Body;
|
|
Assert.Equal(new[] { stayed }, detail["hosts"]!.AsArray().Select(h => h!.GetValue<string>()));
|
|
var weeks = detail["weeks"]!.AsArray();
|
|
Assert.Equal(new[] { WeeksAgo(1), WeeksAgo(2), WeeksAgo(3) }, weeks.Select(w => w.Text("week")));
|
|
Assert.Equal(new[] { 1, 2, 1 }, weeks.Select(w => w!["servers"]!.GetValue<int>()));
|
|
Assert.Equal(new[] { moved }, weeks[0]!["left"]!.AsArray().Select(h => h!.GetValue<string>()));
|
|
Assert.Equal(new[] { stayed }, weeks[1]!["joined"]!.AsArray().Select(h => h!.GetValue<string>()));
|
|
Assert.Equal(new[] { moved }, weeks[2]!["joined"]!.AsArray().Select(h => h!.GetValue<string>()));
|
|
|
|
Assert.Equal("Cloudflare", (await _host.Client().Get("/api/privapub/v1/cdns/cloudflare.com")).Ok().Body.Text("name"));
|
|
Assert.Equal(HttpStatusCode.NotFound, (await _host.Client().Get($"/api/privapub/v1/cdns/none{Guid.NewGuid():N}")).Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Junk_hosts_are_not_an_error()
|
|
{
|
|
Assert.Equal(HttpStatusCode.NotFound, (await _host.Client().Get($"/api/privapub/v1/instances/{new string('a', 300)}")).Status);
|
|
Assert.Empty((await _host.Client().Get($"/api/privapub/v1/instances?host[]=&host[]={new string('a', 300)}")).Ok().Body!.AsArray());
|
|
}
|
|
}
|
|
|
|
public sealed class SelfPlacedHost : PrivaPubHost
|
|
{
|
|
static readonly SemaphoreSlim Boot = new(1, 1);
|
|
static SelfPlacedHost _shared;
|
|
|
|
public static new async Task<SelfPlacedHost> Shared()
|
|
{
|
|
await PrivaPubHost.Shared();
|
|
await Boot.WaitAsync();
|
|
try
|
|
{
|
|
if (_shared == default)
|
|
{
|
|
var host = new SelfPlacedHost();
|
|
_ = host.Services;
|
|
_shared = host;
|
|
}
|
|
return _shared;
|
|
}
|
|
finally
|
|
{
|
|
Boot.Release();
|
|
}
|
|
}
|
|
|
|
protected override IEnumerable<KeyValuePair<string, string>> Settings() => base.Settings().Concat(new Dictionary<string, string>
|
|
{
|
|
["Statistics:Geo:Self:Latitude"] = "45.4642",
|
|
["Statistics:Geo:Self:Longitude"] = "9.19",
|
|
["Statistics:Geo:Self:City"] = "Milan",
|
|
["Statistics:Geo:Self:Country"] = "IT"
|
|
});
|
|
}
|
|
}
|