Files
SocialPub/PrivaPub/Infrastructure/Geo/CdnCatalog.cs
T
thepraandClaude Opus 5.5 436f7da464
Build / Build (push) Successful in 5m11s
Deploy / privapub.thepra.dev (push) Successful in 5m48s
CDNs found by themselves, and servers followed through time
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
2026-10-04 11:33:39 +02:00

157 lines
8.5 KiB
C#

using System.Net;
using System.Text.Json;
namespace PrivaPub.Infrastructure.Geo
{
// How a published list of a CDN's addresses is written.
public enum CdnListFormat
{
Lines,//one CIDR per line (Cloudflare)
StringArray,//a JSON array of addresses or CIDRs (Bunny)
AddressesJson,//{"addresses": [...], "ipv6_addresses"/"addresses_v6": [...]} (Fastly, Gcore)
AwsCloudFront,//AWS ip-ranges.json, CLOUDFRONT entries only
ImpervaJson//{"ipRanges": [...], "ipv6Ranges": [...]}
}
public sealed record CdnList(string Url, CdnListFormat Format, string Method = "GET", string Body = default);
// A CDN, or any edge network whose addresses say nothing about where a server runs. Domain names it in statistics.
public sealed record CdnProvider(string Name, string Domain, int[] Asns, CdnList[] Lists, Func<HttpResponseMessage, bool> Fingerprint)
{
public string Key => Domain ?? Name.ToLowerInvariant();
}
// owner decision (2026-10-04): PrivaPub finds CDNs by itself, three ways, best first. The address ranges a CDN
// publishes (downloaded daily by CdnUpdater), the CDN's fingerprint in the responses PrivaPub already gets from a server
// (EdgeHintsHandler), and the networks (ASN) that carry nothing but a CDN. A network shared with plain hosting (AWS,
// Google, Microsoft, DataPacket) is never listed by ASN: only ranges or fingerprints name those CDNs.
public static class CdnCatalog
{
public static readonly IReadOnlyList<CdnProvider> All = new CdnProvider[]
{
new("Cloudflare", "cloudflare.com", new[] { 13335, 209242 },
new[] { new CdnList("https://www.cloudflare.com/ips-v4", CdnListFormat.Lines), new CdnList("https://www.cloudflare.com/ips-v6", CdnListFormat.Lines) },
r => Has(r, "cf-ray") || ServerIs(r, "cloudflare")),
new("Fastly", "fastly.com", new[] { 54113 },
new[] { new CdnList("https://api.fastly.com/public-ip-list", CdnListFormat.AddressesJson) },
r => Has(r, "x-fastly-request-id") || Has(r, "fastly-restarts") || Value(r, "x-served-by")?.StartsWith("cache-", StringComparison.OrdinalIgnoreCase) == true),
new("Amazon CloudFront", "cloudfront.net", Array.Empty<int>(),
new[] { new CdnList("https://ip-ranges.amazonaws.com/ip-ranges.json", CdnListFormat.AwsCloudFront) },
r => Has(r, "x-amz-cf-id") || Has(r, "x-amz-cf-pop") || Value(r, "via")?.Contains("cloudfront", StringComparison.OrdinalIgnoreCase) == true),
new("Akamai", "akamai.com", new[] { 20940, 16625, 16702, 21342 }, Array.Empty<CdnList>(),
r => Has(r, "akamai-grn") || Has(r, "x-akamai-transformed") || Has(r, "akamai-cache-status") || ServerStarts(r, "AkamaiGHost") || ServerStarts(r, "AkamaiNetStorage")),
new("Bunny", "bunny.net", new[] { 200325 },
new[] { new CdnList("https://api.bunny.net/system/edgeserverlist", CdnListFormat.StringArray), new CdnList("https://api.bunny.net/system/edgeserverlist/IPv6", CdnListFormat.StringArray) },
r => ServerStarts(r, "BunnyCDN") || Has(r, "cdn-pullzone") || Has(r, "cdn-requestid")),
new("Gcore", "gcore.com", Array.Empty<int>(),
new[] { new CdnList("https://api.gcore.com/cdn/public-ip-list", CdnListFormat.AddressesJson) },
r => ServerStarts(r, "gcore")),
new("Imperva", "imperva.com", new[] { 19551 },
new[] { new CdnList("https://my.imperva.com/api/integration/v1/ips", CdnListFormat.ImpervaJson, "POST", "resp_format=json") },
r => Has(r, "x-iinfo") || Value(r, "x-cdn") is "Imperva" or "Incapsula"),
new("CDN77", "cdn77.com", Array.Empty<int>(), Array.Empty<CdnList>(),
r => Has(r, "x-77-nzt") || Has(r, "x-77-cache") || ServerStarts(r, "CDN77")),
new("Edgio", "edg.io", new[] { 15133 }, Array.Empty<CdnList>(),
r => ServerStarts(r, "ECAcc") || ServerStarts(r, "ECS (") || Has(r, "x-ec-custom-error")),
new("Azure Front Door", "azure.microsoft.com", Array.Empty<int>(), Array.Empty<CdnList>(),
r => Has(r, "x-azure-ref")),
new("Google Cloud", "cloud.google.com", Array.Empty<int>(), Array.Empty<CdnList>(),
r => Value(r, "via")?.Contains("google", StringComparison.OrdinalIgnoreCase) == true),
new("Vercel", "vercel.com", Array.Empty<int>(), Array.Empty<CdnList>(),
r => Has(r, "x-vercel-id") || ServerIs(r, "Vercel")),
new("Netlify", "netlify.com", Array.Empty<int>(), Array.Empty<CdnList>(),
r => Has(r, "x-nf-request-id") || ServerIs(r, "Netlify")),
new("Sucuri", "sucuri.net", Array.Empty<int>(), Array.Empty<CdnList>(),
r => Has(r, "x-sucuri-id") || ServerStarts(r, "Sucuri")),
new("DDoS-Guard", "ddos-guard.net", Array.Empty<int>(), Array.Empty<CdnList>(),
r => ServerIs(r, "ddos-guard"))
};
public static CdnProvider ByKey(string key) =>
key == default ? default : All.FirstOrDefault(p => string.Equals(p.Key, key, StringComparison.OrdinalIgnoreCase) || string.Equals(p.Name, key, StringComparison.OrdinalIgnoreCase));
public static CdnProvider OfAsn(int? asn) => asn is { } number ? All.FirstOrDefault(p => p.Asns.Contains(number)) : default;
// The CDN a response passed through: a known fingerprint, or a CDN that names itself in X-CDN.
public static CdnProvider FromResponse(HttpResponseMessage response)
{
if (response == default)
return default;
var known = All.FirstOrDefault(p => p.Fingerprint(response));
if (known != default)
return known;
var named = Value(response, "x-cdn")?.Trim();
return named is { Length: > 1 and <= 40 } && named.All(c => char.IsLetterOrDigit(c) || c is ' ' or '-' or '.' or '_')
? new CdnProvider(named, default, Array.Empty<int>(), Array.Empty<CdnList>(), _ => false)
: default;
}
// The networks a published list holds; an entry that is not an address or a network is an error, never skipped.
public static List<IPNetwork> Parse(CdnListFormat format, string body)
{
IEnumerable<string> entries = format switch
{
CdnListFormat.Lines => body.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Where(l => !l.StartsWith('#')),
CdnListFormat.StringArray => Root(body),
CdnListFormat.AddressesJson => Json(body, "addresses", "ipv6_addresses", "addresses_v6"),
CdnListFormat.ImpervaJson => Json(body, "ipRanges", "ipv6Ranges"),
CdnListFormat.AwsCloudFront => CloudFront(body),
_ => Enumerable.Empty<string>()
};
return entries.Select(Network).ToList();
}
static IPNetwork Network(string entry)
{
if (IPNetwork.TryParse(entry, out var network))
return network;
if (IPAddress.TryParse(entry, out var address))
return new IPNetwork(address, address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork ? 32 : 128);
throw new FormatException($"not an address or a network: {entry}");
}
static List<string> Root(string body)
{
using var document = JsonDocument.Parse(body);
return Strings(document.RootElement);
}
static IEnumerable<string> Json(string body, params string[] properties)
{
using var document = JsonDocument.Parse(body);
var entries = new List<string>();
foreach (var property in properties)
if (document.RootElement.TryGetProperty(property, out var array))
entries.AddRange(Strings(array));
return entries;
}
static IEnumerable<string> CloudFront(string body)
{
using var document = JsonDocument.Parse(body);
var entries = new List<string>();
foreach (var (array, field) in new[] { ("prefixes", "ip_prefix"), ("ipv6_prefixes", "ipv6_prefix") })
if (document.RootElement.TryGetProperty(array, out var prefixes))
foreach (var prefix in prefixes.EnumerateArray())
if (prefix.TryGetProperty("service", out var service) && service.GetString() == "CLOUDFRONT" && prefix.TryGetProperty(field, out var value))
entries.Add(value.GetString());
return entries;
}
static List<string> Strings(JsonElement array) =>
array.ValueKind == JsonValueKind.Array ? array.EnumerateArray().Select(e => e.GetString()).ToList() : throw new FormatException("not a list");
static bool Has(HttpResponseMessage response, string header) =>
response.Headers.Contains(header) || response.Content?.Headers.Contains(header) == true;
static string Value(HttpResponseMessage response, string header) =>
response.Headers.TryGetValues(header, out var values) || response.Content?.Headers.TryGetValues(header, out values) == true
? string.Join(", ", values)
: default;
static bool ServerIs(HttpResponseMessage response, string name) => string.Equals(Value(response, "server")?.Trim(), name, StringComparison.OrdinalIgnoreCase);
static bool ServerStarts(HttpResponseMessage response, string prefix) => Value(response, "server")?.TrimStart().StartsWith(prefix, StringComparison.OrdinalIgnoreCase) == true;
}
}