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 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 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(), 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(), 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(), 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(), Array.Empty(), r => Has(r, "x-77-nzt") || Has(r, "x-77-cache") || ServerStarts(r, "CDN77")), new("Edgio", "edg.io", new[] { 15133 }, Array.Empty(), r => ServerStarts(r, "ECAcc") || ServerStarts(r, "ECS (") || Has(r, "x-ec-custom-error")), new("Azure Front Door", "azure.microsoft.com", Array.Empty(), Array.Empty(), r => Has(r, "x-azure-ref")), new("Google Cloud", "cloud.google.com", Array.Empty(), Array.Empty(), r => Value(r, "via")?.Contains("google", StringComparison.OrdinalIgnoreCase) == true), new("Vercel", "vercel.com", Array.Empty(), Array.Empty(), r => Has(r, "x-vercel-id") || ServerIs(r, "Vercel")), new("Netlify", "netlify.com", Array.Empty(), Array.Empty(), r => Has(r, "x-nf-request-id") || ServerIs(r, "Netlify")), new("Sucuri", "sucuri.net", Array.Empty(), Array.Empty(), r => Has(r, "x-sucuri-id") || ServerStarts(r, "Sucuri")), new("DDoS-Guard", "ddos-guard.net", Array.Empty(), Array.Empty(), 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(), Array.Empty(), _ => 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 Parse(CdnListFormat format, string body) { IEnumerable 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() }; 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 Root(string body) { using var document = JsonDocument.Parse(body); return Strings(document.RootElement); } static IEnumerable Json(string body, params string[] properties) { using var document = JsonDocument.Parse(body); var entries = new List(); foreach (var property in properties) if (document.RootElement.TryGetProperty(property, out var array)) entries.AddRange(Strings(array)); return entries; } static IEnumerable CloudFront(string body) { using var document = JsonDocument.Parse(body); var entries = new List(); 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 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; } }