/** * IP Tools * Pokemon Showdown - http://pokemonshowdown.com/ * * IPTools file has various tools for IP parsing and IP-based blocking. * * These include DNSBLs: DNS-based blackhole lists, which list IPs known for * running proxies, spamming, or other abuse. * * We also maintain our own database of datacenter IP ranges (usually * proxies). These are taken from https://github.com/client9/ipcat * but include our own database as well. * * @license MIT */ const BLOCKLISTS = ['sbl.spamhaus.org', 'rbl.efnetrbl.org']; const HOSTS_FILE = 'config/hosts.csv'; const PROXIES_FILE = 'config/proxies.csv'; import * as dns from 'dns'; import { FS, Net, Utils } from '../lib'; export interface AddressRange { minIP: number; maxIP: number; host?: string; } function removeNohost(hostname: string) { // Convert from old domain.tld.type-nohost format to new domain.tld?/type format if (hostname?.includes('-nohost')) { const parts = hostname.split('.'); const suffix = parts.pop(); return `${parts.join('.')}?/${suffix?.replace('-nohost', '')}`; } return hostname; } export const IPTools = new class { readonly dnsblCache = new Map([ ['127.0.0.1', null], ]); readonly connectionTestCache = new Map(); readonly ipRegex = /^(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])$/; readonly ipRangeRegex = /^(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9]|\*)){0,2}\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9]|\*)$/; readonly hostRegex = /^.+\..{2,}$/; async lookup(ip: string) { const [dnsbl, host] = await Promise.all([ IPTools.queryDnsbl(ip), IPTools.getHost(ip), ]); const shortHost = this.shortenHost(host); const hostType = this.getHostType(shortHost, ip); return { dnsbl, host, shortHost, hostType }; } queryDnsblLoop(ip: string, callback: (val: string | null) => void, reversedIpDot: string, index: number) { if (index >= BLOCKLISTS.length) { // not in any blocklist IPTools.dnsblCache.set(ip, null); callback(null); return; } const blocklist = BLOCKLISTS[index]; dns.lookup(reversedIpDot + blocklist, 4, (err, res) => { if (!err) { // blocked IPTools.dnsblCache.set(ip, blocklist); callback(blocklist); return; } // not blocked, try next blocklist IPTools.queryDnsblLoop(ip, callback, reversedIpDot, index + 1); }); } /** * IPTools.queryDnsbl(ip, callback) * * Calls callb * ack(blocklist), where blocklist is the blocklist domain * if the passed IP is in a blocklist, or null if the IP is not in * any blocklist. * * Return value matches isBlocked when treated as a boolean. */ queryDnsbl(ip: string) { if (!Config.dnsbl) return Promise.resolve(null); if (IPTools.dnsblCache.has(ip)) { return Promise.resolve(IPTools.dnsblCache.get(ip) || null); } const reversedIpDot = ip.split('.').reverse().join('.') + '.'; return new Promise((resolve, reject) => { IPTools.queryDnsblLoop(ip, resolve, reversedIpDot, 0); }); } /********************************************************* * IP parsing *********************************************************/ ipToNumber(ip: string) { ip = ip.trim(); if (ip.includes(':') && !ip.includes('.')) { // IPv6, which PS does not support return null; } if (ip.startsWith('::ffff:')) ip = ip.slice(7); else if (ip.startsWith('::')) ip = ip.slice(2); let num = 0; const parts = ip.split('.'); if (parts.length !== 4) return null; for (const part of parts) { num *= 256; const partAsInt = Utils.parseExactInt(part); if (isNaN(partAsInt) || partAsInt < 0 || partAsInt > 255) return null; num += partAsInt; } return num; } numberToIP(num: number) { const ipParts: string[] = []; if (num < 0 || num >= 256 ** 4 || num !== Math.trunc(num)) return null; while (num) { const part = num % 256; num = (num - part) / 256; ipParts.unshift(part.toString()); } while (ipParts.length < 4) ipParts.unshift('0'); if (ipParts.length !== 4) return null; return ipParts.join('.'); } getCidrRange(cidr: string): AddressRange | null { if (!cidr) return null; const index = cidr.indexOf('/'); if (index <= 0) { const ip = IPTools.ipToNumber(cidr); if (ip === null) return null; return { minIP: ip, maxIP: ip }; } const low = IPTools.ipToNumber(cidr.slice(0, index)); const bits = Utils.parseExactInt(cidr.slice(index + 1)); // fun fact: IPTools fails if bits <= 1 because JavaScript // does << with signed int32s. if (low === null || !bits || bits < 2 || bits > 32) return null; const high = low + (1 << (32 - bits)) - 1; return { minIP: low, maxIP: high }; } /** Is this an IP range supported by `stringToRange`? Note that exact IPs are also valid IP ranges. */ isValidRange(range: string): boolean { return IPTools.stringToRange(range) !== null; } stringToRange(this: void, range: string | null): AddressRange | null { if (!range) return null; if (range.endsWith('*')) { const parts = range.replace('.*', '').split('.'); if (parts.length > 3) return null; const [a, b, c] = parts; const minIP = IPTools.ipToNumber(`${a || '0'}.${b || '0'}.${c || '0'}.0`); const maxIP = IPTools.ipToNumber(`${a || '255'}.${b || '255'}.${c || '255'}.255`); if (minIP === null || maxIP === null) return null; return { minIP, maxIP }; } const index = range.indexOf('-'); if (index <= 0) { if (range.includes('/')) return IPTools.getCidrRange(range); const ip = IPTools.ipToNumber(range); if (ip === null) return null; return { maxIP: ip, minIP: ip }; } const minIP = IPTools.ipToNumber(range.slice(0, index)); const maxIP = IPTools.ipToNumber(range.slice(index + 1)); if (minIP === null || maxIP === null || maxIP < minIP) return null; return { minIP, maxIP }; } rangeToString(range: AddressRange, sep = '-') { return `${this.numberToIP(range.minIP)}${sep}${this.numberToIP(range.maxIP)}`; } /****************************** * Range management functions * ******************************/ checkPattern(patterns: AddressRange[], num: number | null) { if (num === null) return false; for (const pattern of patterns) { if (num >= pattern.minIP && num <= pattern.maxIP) { return true; } } return false; } /** * Returns a checker function for the passed IP range or array of * ranges. The checker function returns true if its passed IP is * in the range. */ checker(rangeString: string | string[]): (ip: string) => boolean { if (!rangeString?.length) return () => false; let ranges: AddressRange[] = []; if (typeof rangeString === 'string') { const rangePatterns = IPTools.stringToRange(rangeString); if (rangePatterns) ranges = [rangePatterns]; } else { ranges = rangeString.map(IPTools.stringToRange).filter(x => x) as AddressRange[]; } return (ip: string) => { const ipNumber = IPTools.ipToNumber(ip); return IPTools.checkPattern(ranges, ipNumber); }; } /** * Proxy and host management functions */ ranges: (AddressRange & { host: string })[] = []; singleIPOpenProxies = new Set(); torProxyIps = new Set(); proxyHosts = new Set(); residentialHosts = new Set(); mobileHosts = new Set(); async loadHostsAndRanges() { const data = await FS(HOSTS_FILE).readIfExists() + await FS(PROXIES_FILE).readIfExists(); // Strip carriage returns for Windows compatibility const rows = data.split('\n').map(row => row.replace('\r', '')); const ranges = []; for (const row of rows) { if (!row) continue; let [type, hostOrLowIP, highIP, host] = row.split(','); if (!hostOrLowIP) continue; // Handle legacy data format host = removeNohost(host); hostOrLowIP = removeNohost(hostOrLowIP); switch (type) { case 'IP': IPTools.singleIPOpenProxies.add(hostOrLowIP); break; case 'HOST': IPTools.proxyHosts.add(hostOrLowIP); break; case 'RESIDENTIAL': IPTools.residentialHosts.add(hostOrLowIP); break; case 'MOBILE': IPTools.mobileHosts.add(hostOrLowIP); break; case 'RANGE': if (!host) continue; const minIP = IPTools.ipToNumber(hostOrLowIP); if (minIP === null) { Monitor.error(`Bad IP address in host or proxy file: '${hostOrLowIP}'`); continue; } const maxIP = IPTools.ipToNumber(highIP); if (maxIP === null) { Monitor.error(`Bad IP address in host or proxy file: '${highIP}'`); continue; } const range = { host: IPTools.urlToHost(host), maxIP, minIP }; if (range.maxIP < range.minIP) throw new Error(`Bad range at ${hostOrLowIP}.`); ranges.push(range); break; } } IPTools.ranges = ranges; IPTools.sortRanges(); } saveHostsAndRanges() { let hostsData = ''; let proxiesData = ''; for (const ip of IPTools.singleIPOpenProxies) { proxiesData += `IP,${ip}\n`; } for (const host of IPTools.proxyHosts) { proxiesData += `HOST,${host}\n`; } for (const host of IPTools.residentialHosts) { hostsData += `RESIDENTIAL,${host}\n`; } for (const host of IPTools.mobileHosts) { hostsData += `MOBILE,${host}\n`; } IPTools.sortRanges(); for (const range of IPTools.ranges) { const data = `RANGE,${IPTools.rangeToString(range, ',')}${range.host ? `,${range.host}` : ``}\n`; if (range.host?.endsWith('/proxy')) { proxiesData += data; } else { hostsData += data; } } void FS(HOSTS_FILE).write(hostsData); void FS(PROXIES_FILE).write(proxiesData); } addOpenProxies(ips: string[]) { for (const ip of ips) { IPTools.singleIPOpenProxies.add(ip); } return IPTools.saveHostsAndRanges(); } addProxyHosts(hosts: string[]) { for (const host of hosts) { IPTools.proxyHosts.add(host); } return IPTools.saveHostsAndRanges(); } addMobileHosts(hosts: string[]) { for (const host of hosts) { IPTools.mobileHosts.add(host); } return IPTools.saveHostsAndRanges(); } addResidentialHosts(hosts: string[]) { for (const host of hosts) { IPTools.residentialHosts.add(host); } return IPTools.saveHostsAndRanges(); } removeOpenProxies(ips: string[]) { for (const ip of ips) { IPTools.singleIPOpenProxies.delete(ip); } return IPTools.saveHostsAndRanges(); } removeResidentialHosts(hosts: string[]) { for (const host of hosts) { IPTools.residentialHosts.delete(host); } return IPTools.saveHostsAndRanges(); } removeProxyHosts(hosts: string[]) { for (const host of hosts) { IPTools.proxyHosts.delete(host); } return IPTools.saveHostsAndRanges(); } removeMobileHosts(hosts: string[]) { for (const host of hosts) { IPTools.mobileHosts.delete(host); } return IPTools.saveHostsAndRanges(); } rangeIntersects(a: AddressRange, b: AddressRange) { try { this.checkRangeConflicts(a, [b]); } catch { return true; } return false; } checkRangeConflicts(insertion: AddressRange, sortedRanges: AddressRange[], widen?: boolean) { if (insertion.maxIP < insertion.minIP) { throw new Error( `Invalid data for address range ${IPTools.rangeToString(insertion)} (${insertion.host})` ); } let iMin = 0; let iMax = sortedRanges.length; while (iMin < iMax) { const i = Math.floor((iMax + iMin) / 2); if (insertion.minIP > sortedRanges[i].minIP) { iMin = i + 1; } else { iMax = i; } } if (iMin < sortedRanges.length) { const next = sortedRanges[iMin]; if (insertion.minIP === next.minIP && insertion.maxIP === next.maxIP) { throw new Error(`The address range ${IPTools.rangeToString(insertion)} (${insertion.host}) already exists`); } if (insertion.minIP <= next.minIP && insertion.maxIP >= next.maxIP) { if (widen) { if (sortedRanges[iMin + 1]?.minIP <= insertion.maxIP) { throw new Error("You can only widen one address range at a time."); } return iMin; } throw new Error( `Too wide: ${IPTools.rangeToString(insertion)} (${insertion.host})\n` + `Intersects with: ${IPTools.rangeToString(next)} (${next.host})` ); } if (insertion.maxIP >= next.minIP) { throw new Error( `Could not insert: ${IPTools.rangeToString(insertion)} ${insertion.host}\n` + `Intersects with: ${IPTools.rangeToString(next)} (${next.host})` ); } } if (iMin > 0) { const prev = sortedRanges[iMin - 1]; if (insertion.minIP >= prev.minIP && insertion.maxIP <= prev.maxIP) { throw new Error( `Too narrow: ${IPTools.rangeToString(insertion)} (${insertion.host})\n` + `Intersects with: ${IPTools.rangeToString(prev)} (${prev.host})` ); } if (insertion.minIP <= prev.maxIP) { throw new Error( `Could not insert: ${IPTools.rangeToString(insertion)} (${insertion.host})\n` + `Intersects with: ${IPTools.rangeToString(prev)} (${prev.host})` ); } } } /********************************************************* * Range handling functions *********************************************************/ urlToHost(url: string) { if (url.startsWith('http://')) url = url.slice(7); if (url.startsWith('https://')) url = url.slice(8); if (url.startsWith('www.')) url = url.slice(4); const slashIndex = url.indexOf('/'); if (slashIndex > 0 && url[slashIndex - 1] !== '?') url = url.slice(0, slashIndex); return url; } sortRanges() { Utils.sortBy(IPTools.ranges, range => range.minIP); } getRange(minIP: number, maxIP: number) { for (const range of IPTools.ranges) { if (range.minIP === minIP && range.maxIP === maxIP) return range; } } addRange(range: AddressRange & { host: string }) { if (IPTools.getRange(range.minIP, range.maxIP)) { IPTools.removeRange(range.minIP, range.maxIP); } IPTools.ranges.push(range); return IPTools.saveHostsAndRanges(); } removeRange(minIP: number, maxIP: number) { IPTools.ranges = IPTools.ranges.filter(dc => dc.minIP !== minIP || dc.maxIP !== maxIP); return IPTools.saveHostsAndRanges(); } /** * Will not reject; IPs with no RDNS entry will resolve to * '[byte1].[byte2]?/unknown'. */ getHost(ip: string) { return new Promise(resolve => { if (!ip) { resolve(''); return; } const ipNumber = IPTools.ipToNumber(ip); if (ipNumber === null) throw new Error(`Bad IP address: '${ip}'`); for (const range of IPTools.ranges) { if (ipNumber >= range.minIP && ipNumber <= range.maxIP) { resolve(range.host); return; } } dns.reverse(ip, (err, hosts) => { if (err) { resolve(`${ip.split('.').slice(0, 2).join('.')}?/unknown`); return; } if (!hosts?.[0]) { if (ip.startsWith('50.')) { resolve('comcast.net?/res'); } else if (ipNumber >= telstraRange.minIP && ipNumber <= telstraRange.maxIP) { resolve(telstraRange.host); } else { this.testConnection(ip, result => { if (result) { resolve(`${ip.split('.').slice(0, 2).join('.')}?/proxy`); } else { resolve(`${ip.split('.').slice(0, 2).join('.')}?/unknown`); } }); } } else { resolve(hosts[0]); } }); }); } /** * Does this IP respond to port 80? In theory, proxies are likely to * respond, while residential connections are likely to reject connections. * * Callback is guaranteed to be called exactly once, within a 1000ms * timeout. */ testConnection(ip: string, callback: (result: boolean) => void) { const cachedValue = this.connectionTestCache.get(ip); if (cachedValue !== undefined) { return callback(cachedValue); } // Node.js's documentation does not make this easy to write. I discovered // this behavior by manual testing: // A successful connection emits 'connect', which you should react to // with socket.destroy(), which emits 'close'. // Some IPs instantly reject connections, emitting 'error' followed // immediately by 'close'. // Some IPs just never respond, leaving you to time out. Node will // emit the 'timeout' event, but not actually do anything else, leaving // you to manually use socket.destroy(), which emits 'close' let connected = false; const socket = require('net').createConnection({ port: 80, host: ip, timeout: 1000, }, () => { connected = true; this.connectionTestCache.set(ip, true); socket.destroy(); return callback(true); }); socket.on('error', () => {}); socket.on('timeout', () => socket.destroy()); socket.on('close', () => { if (!connected) { this.connectionTestCache.set(ip, false); return callback(false); } }); } shortenHost(host: string) { if (host.split('.').pop()?.includes('/')) return host; // It has a suffix, e.g. leaseweb.com?/proxy let dotLoc = host.lastIndexOf('.'); const tld = host.slice(dotLoc); if (tld === '.uk' || tld === '.au' || tld === '.br') dotLoc = host.lastIndexOf('.', dotLoc - 1); dotLoc = host.lastIndexOf('.', dotLoc - 1); return host.slice(dotLoc + 1); } /** * Host types: * - 'res' - normal residential ISP * - 'shared' - like res, but shared among many people: bans will have collateral damage * - 'mobile' - like res, but unstable IP (IP bans don't work) * - 'proxy' - datacenters, VPNs, proxy services, other untrustworthy sources * (note that bots will usually be hosted on these) * - 'res?' - likely res, but host not specifically whitelisted * - 'unknown' - no rdns entry, treat with suspicion */ getHostType(host: string, ip: string) { if (Punishments.isSharedIp(ip)) { return 'shared'; } if (this.singleIPOpenProxies.has(ip) || this.torProxyIps.has(ip)) { // single-IP open proxies return 'proxy'; } if (/^he\.net(\?|)\/proxy$/.test(host)) { // Known to only be VPN services if (['74.82.60.', '72.52.87.', '65.49.126.'].some(range => ip.startsWith(range))) { return 'proxy'; } // Hurricane Electric has an annoying habit of having residential // internet and datacenters on the same IP ranges - we get a lot of // legitimate users as well as spammers on VPNs from HE. // This splits the difference and treats it like any other unknown IP. return 'unknown'; } // There were previously special cases for // 'digitalocean.proxy-nohost', 'servihosting.es.proxy-nohost' // DO is commonly used to host bots; I don't know who whitelisted // servihosting but I assume for a similar reason. This isn't actually // tenable; any service that can host bots can and does also host proxies. if (this.proxyHosts.has(host) || host.endsWith('/proxy')) { return 'proxy'; } if (this.residentialHosts.has(host) || host.endsWith('/res')) { return 'res'; } if (this.mobileHosts.has(host) || host.endsWith('/mobile')) { return 'mobile'; } if (/^ip-[0-9]+-[0-9]+-[0-9]+\.net$/.test(host) || /^ip-[0-9]+-[0-9]+-[0-9]+\.eu$/.test(host)) { // OVH return 'proxy'; } if (host.endsWith('/unknown')) { // rdns entry doesn't exist, and IP doesn't respond to a probe on port 80 return 'unknown'; } // rdns entry exists but is unrecognized return 'res?'; } async updateTorRanges() { try { const raw = await Net('https://check.torproject.org/torbulkexitlist').get(); const torIps = raw.split('\n'); for (const ip of torIps) { if (this.ipRegex.test(ip)) { this.torProxyIps.add(ip); } } } catch {} } }; const telstraRange: AddressRange & { host: string } = { minIP: IPTools.ipToNumber("101.160.0.0")!, maxIP: IPTools.ipToNumber("101.191.255.255")!, host: 'telstra.net?/res', }; export default IPTools; void IPTools.updateTorRanges();