Files
fluxer/fluxer_media_proxy/src/lib/CloudflareIPService.ts
Hampus Kraft 2f557eda8c initial commit
2026-01-01 21:05:54 +00:00

275 lines
7.3 KiB
TypeScript

/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {isIP} from 'node:net';
import {Logger} from '~/Logger';
import {sendRequest} from '~/utils/FetchUtils';
const CLOUDFLARE_IPV4_URL = 'https://www.cloudflare.com/ips-v4';
const CLOUDFLARE_IPV6_URL = 'https://www.cloudflare.com/ips-v6';
const REFRESH_INTERVAL_MS = 10 * 60 * 1000;
type IPFamily = 4 | 6;
interface ParsedIP {
family: IPFamily;
bytes: Buffer;
}
interface CidrEntry {
family: IPFamily;
network: Buffer;
prefixLength: number;
}
export class CloudflareIPService {
private cidrs: Array<CidrEntry> = [];
private refreshTimer?: NodeJS.Timeout;
async initialize(): Promise<void> {
await this.refreshNow();
this.refreshTimer = setInterval(() => {
this.refreshNow().catch((error) => {
Logger.error({error}, 'Failed to refresh Cloudflare IP ranges; keeping existing ranges');
});
}, REFRESH_INTERVAL_MS);
this.refreshTimer.unref();
}
isFromCloudflare(ip: string | undefined | null): boolean {
if (!ip) return false;
if (this.cidrs.length === 0) return false;
const parsed = parseIP(ip);
if (!parsed) return false;
for (const cidr of this.cidrs) {
if (cidr.family !== parsed.family) continue;
if (ipInCidr(parsed, cidr)) return true;
}
return false;
}
private async refreshNow(): Promise<void> {
const [v4Ranges, v6Ranges] = await Promise.all([
this.fetchRanges(CLOUDFLARE_IPV4_URL),
this.fetchRanges(CLOUDFLARE_IPV6_URL),
]);
const nextCidrs: Array<CidrEntry> = [];
for (const range of [...v4Ranges, ...v6Ranges]) {
const parsed = parseCIDR(range);
if (!parsed) continue;
nextCidrs.push(parsed);
}
if (nextCidrs.length === 0) {
throw new Error('Cloudflare IP list refresh returned no valid ranges');
}
this.cidrs = nextCidrs;
Logger.info({count: this.cidrs.length}, 'Refreshed Cloudflare IP ranges');
}
private async fetchRanges(url: string): Promise<Array<string>> {
const response = await sendRequest({url, method: 'GET'});
if (response.status !== 200) {
throw new Error(`Failed to download Cloudflare IPs from ${url} (status ${response.status})`);
}
const text = await CloudflareIPService.streamToString(response.stream);
return text
.split('\n')
.map((l) => l.trim())
.filter((l) => l.length > 0);
}
private static async streamToString(stream: NodeJS.ReadableStream): Promise<string> {
const chunks: Array<Buffer> = [];
for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks).toString('utf8');
}
}
function parseIP(ip: string): ParsedIP | null {
const ipWithoutZone = ip.split('%', 1)[0].trim();
if (!ipWithoutZone) return null;
const family = isIP(ipWithoutZone);
if (!family) return null;
if (family === 6 && ipWithoutZone.includes('.')) {
const lastColon = ipWithoutZone.lastIndexOf(':');
const ipv4Part = ipWithoutZone.slice(lastColon + 1);
const bytes = parseIPv4(ipv4Part);
if (!bytes) return null;
return {family: 4, bytes};
}
if (family === 4) {
const bytes = parseIPv4(ipWithoutZone);
if (!bytes) return null;
return {family: 4, bytes};
}
const bytes = parseIPv6(ipWithoutZone);
if (!bytes) return null;
return {family: 6, bytes};
}
function parseIPv4(ip: string): Buffer | null {
const parts = ip.split('.');
if (parts.length !== 4) return null;
const bytes = Buffer.alloc(4);
for (let i = 0; i < 4; i++) {
const part = parts[i];
if (!/^\d+$/.test(part)) return null;
const octet = Number(part);
if (!Number.isInteger(octet) || octet < 0 || octet > 255) return null;
bytes[i] = octet;
}
return bytes;
}
function parseIPv6(ip: string): Buffer | null {
const addr = ip.trim();
const hasDoubleColon = addr.includes('::');
let headPart = '';
let tailPart = '';
if (hasDoubleColon) {
const parts = addr.split('::');
if (parts.length !== 2) return null;
[headPart, tailPart] = parts;
} else {
headPart = addr;
tailPart = '';
}
const headGroups = headPart ? headPart.split(':').filter((g) => g.length > 0) : [];
const tailGroups = tailPart ? tailPart.split(':').filter((g) => g.length > 0) : [];
if (!hasDoubleColon && headGroups.length !== 8) return null;
const totalGroups = headGroups.length + tailGroups.length;
if (totalGroups > 8) return null;
const zerosToInsert = 8 - totalGroups;
const groups: Array<number> = [];
for (const g of headGroups) {
const value = parseInt(g, 16);
if (!Number.isFinite(value) || value < 0 || value > 0xffff) return null;
groups.push(value);
}
for (let i = 0; i < zerosToInsert; i++) {
groups.push(0);
}
for (const g of tailGroups) {
const value = parseInt(g, 16);
if (!Number.isFinite(value) || value < 0 || value > 0xffff) return null;
groups.push(value);
}
if (groups.length !== 8) return null;
const bytes = Buffer.alloc(16);
for (let i = 0; i < 8; i++) {
bytes[i * 2] = (groups[i] >> 8) & 0xff;
bytes[i * 2 + 1] = groups[i] & 0xff;
}
return bytes;
}
function parseCIDR(cidr: string): CidrEntry | null {
const trimmed = cidr.trim();
if (!trimmed) return null;
const slashIdx = trimmed.indexOf('/');
if (slashIdx === -1) return null;
const ipPart = trimmed.slice(0, slashIdx).trim();
const prefixPart = trimmed.slice(slashIdx + 1).trim();
if (!ipPart || !prefixPart) return null;
const prefixLength = Number(prefixPart);
if (!Number.isInteger(prefixLength) || prefixLength < 0) return null;
const parsedIP = parseIP(ipPart);
if (!parsedIP) return null;
const maxPrefix = parsedIP.family === 4 ? 32 : 128;
if (prefixLength > maxPrefix) return null;
const network = Buffer.from(parsedIP.bytes);
const fullBytes = Math.floor(prefixLength / 8);
const remainingBits = prefixLength % 8;
if (fullBytes < network.length) {
if (remainingBits !== 0 && fullBytes < network.length) {
const mask = (0xff << (8 - remainingBits)) & 0xff;
network[fullBytes] &= mask;
for (let i = fullBytes + 1; i < network.length; i++) {
network[i] = 0;
}
} else {
for (let i = fullBytes; i < network.length; i++) {
network[i] = 0;
}
}
}
return {
family: parsedIP.family,
network,
prefixLength,
};
}
function ipInCidr(ip: ParsedIP, cidr: CidrEntry): boolean {
if (ip.family !== cidr.family) return false;
const prefixLength = cidr.prefixLength;
const bytesToCheck = Math.floor(prefixLength / 8);
const remainingBits = prefixLength % 8;
for (let i = 0; i < bytesToCheck; i++) {
if (ip.bytes[i] !== cidr.network[i]) return false;
}
if (remainingBits > 0) {
const mask = (0xff << (8 - remainingBits)) & 0xff;
const idx = bytesToCheck;
if (((ip.bytes[idx] ?? 0) & mask) !== ((cidr.network[idx] ?? 0) & mask)) {
return false;
}
}
return true;
}