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
157 lines
8.5 KiB
C#
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;
|
|
}
|
|
}
|