Spaces:
Running
Running
/** | |
* 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<string, string | null>([ | |
['127.0.0.1', null], | |
]); | |
readonly connectionTestCache = new Map<string, boolean>(); | |
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<string | null>((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<string>(); | |
torProxyIps = new Set<string>(); | |
proxyHosts = new Set<string>(); | |
residentialHosts = new Set<string>(); | |
mobileHosts = new Set<string>(); | |
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<string>(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(); | |