diff --git a/server/chat-commands/info.ts b/server/chat-commands/info.ts index 76f85a3d7a..d59ae0fed8 100644 --- a/server/chat-commands/info.ts +++ b/server/chat-commands/info.ts @@ -243,7 +243,7 @@ export const commands: Chat.ChatCommands = { status.push(punishMsg); } } - if (Punishments.sharedIps.has(ip)) { + if (Punishments.isSharedIp(ip)) { let sharedStr = 'shared'; if (Punishments.sharedIps.get(ip)) { sharedStr += `: ${Punishments.sharedIps.get(ip)}`; diff --git a/server/chat-commands/moderation.ts b/server/chat-commands/moderation.ts index afe81e0c0d..0815ebb30f 100644 --- a/server/chat-commands/moderation.ts +++ b/server/chat-commands/moderation.ts @@ -902,7 +902,7 @@ export const commands: Chat.ChatCommands = { let affected = []; if (targetUser) { - const ignoreAlts = Punishments.sharedIps.has(targetUser.latestIp); + const ignoreAlts = Punishments.isSharedIp(targetUser.latestIp); affected = await Punishments.lock(targetUser, duration, null, ignoreAlts, publicReason); } else { affected = await Punishments.lock(userid, duration, null, false, publicReason); diff --git a/server/chat-plugins/helptickets.ts b/server/chat-plugins/helptickets.ts index 5bec4e2f18..a4f7c321d7 100644 --- a/server/chat-plugins/helptickets.ts +++ b/server/chat-plugins/helptickets.ts @@ -768,7 +768,7 @@ export function notifyStaff() { function checkIp(ip: string) { for (const t in tickets) { - if (tickets[t].ip === ip && tickets[t].open && !Punishments.sharedIps.has(ip)) { + if (tickets[t].ip === ip && tickets[t].open && !Punishments.isSharedIp(ip)) { return tickets[t]; } } @@ -2097,7 +2097,7 @@ export const commands: Chat.ChatCommands = { return this.parse(`/join help-${ticket.userid}`); } if (Monitor.countTickets(user.latestIp)) { - const maxTickets = Punishments.sharedIps.has(user.latestIp) ? `50` : `5`; + const maxTickets = Punishments.isSharedIp(user.latestIp) ? `50` : `5`; return this.popupReply(this.tr`Due to high load, you are limited to creating ${maxTickets} tickets every hour.`); } let [ diff --git a/server/chat-plugins/hosts.ts b/server/chat-plugins/hosts.ts index 8e8f6b3b75..7687d68ac2 100644 --- a/server/chat-plugins/hosts.ts +++ b/server/chat-plugins/hosts.ts @@ -438,9 +438,24 @@ export const commands: Chat.ChatCommands = { if (!target) return this.parse('/help markshared'); checkCanPerform(this, user, 'globalban'); const [ip, note] = this.splitOne(target); - if (!IPTools.ipRegex.test(ip)) return this.errorReply("Please enter a valid IP address."); + if (!IPTools.ipRegex.test(ip)) { + const pattern = IPTools.stringToRange(ip); + if (!pattern) { + return this.errorReply("Please enter a valid IP address."); + } + if (!user.can('rangeban')) { + return this.errorReply('Only upper staff can markshare ranges.'); + } + for (const range of Punishments.sharedRanges.keys()) { + if (IPTools.rangeIntersects(range, pattern)) { + return this.errorReply( + `Range ${IPTools.rangeToString(pattern)} intersects with shared range ${IPTools.rangeToString(range)}` + ); + } + } + } - if (Punishments.sharedIps.has(ip)) return this.errorReply("This IP is already marked as shared."); + if (Punishments.isSharedIp(ip)) return this.errorReply("This IP is already marked as shared."); if (Punishments.isBlacklistedSharedIp(ip)) { return this.errorReply(`This IP is blacklisted from being marked as shared.`); } @@ -464,7 +479,7 @@ export const commands: Chat.ChatCommands = { checkCanPerform(this, user, 'globalban'); if (!IPTools.ipRegex.test(target)) return this.errorReply("Please enter a valid IP address."); - if (!Punishments.sharedIps.has(target)) return this.errorReply("This IP isn't marked as shared."); + if (!Punishments.isSharedIp(target)) return this.errorReply("This IP isn't marked as shared."); Punishments.removeSharedIp(target); @@ -512,7 +527,7 @@ export const commands: Chat.ChatCommands = { } } } else { - if (Punishments.sharedIps.has(ip)) this.parse(`/unmarkshared ${ip}`); + if (Punishments.isSharedIp(ip)) this.parse(`/unmarkshared ${ip}`); } const reason = reasonArr.join(','); diff --git a/server/ip-tools.ts b/server/ip-tools.ts index 9471e7e572..73a7f76421 100644 --- a/server/ip-tools.ts +++ b/server/ip-tools.ts @@ -184,6 +184,9 @@ export const IPTools = new class { 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 * @@ -295,7 +298,7 @@ export const IPTools = new class { } IPTools.sortRanges(); for (const range of IPTools.ranges) { - const data = `RANGE,${IPTools.numberToIP(range.minIP)},${IPTools.numberToIP(range.maxIP)}${range.host ? `,${range.host}` : ``}\n`; + const data = `RANGE,${IPTools.rangeToString(range, ',')}${range.host ? `,${range.host}` : ``}\n`; if (range.host?.endsWith('/proxy')) { proxiesData += data; } else { @@ -362,10 +365,19 @@ export const IPTools = new class { 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.numberToIP(insertion.minIP)}-${IPTools.numberToIP(insertion.maxIP)} (${insertion.host})` + `Invalid data for address range ${IPTools.rangeToString(insertion)} (${insertion.host})` ); } @@ -382,7 +394,7 @@ export const IPTools = new class { if (iMin < sortedRanges.length) { const next = sortedRanges[iMin]; if (insertion.minIP === next.minIP && insertion.maxIP === next.maxIP) { - throw new Error(`The address range ${IPTools.numberToIP(insertion.minIP)}-${IPTools.numberToIP(insertion.maxIP)} (${insertion.host}) already exists`); + throw new Error(`The address range ${IPTools.rangeToString(insertion)} (${insertion.host}) already exists`); } if (insertion.minIP <= next.minIP && insertion.maxIP >= next.maxIP) { if (widen) { @@ -392,14 +404,14 @@ export const IPTools = new class { return iMin; } throw new Error( - `Too wide: ${IPTools.numberToIP(insertion.minIP)}-${IPTools.numberToIP(insertion.maxIP)} (${insertion.host})\n` + - `Intersects with: ${IPTools.numberToIP(next.minIP)}-${IPTools.numberToIP(next.maxIP)} (${next.host})` + `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.numberToIP(insertion.minIP)}-${IPTools.numberToIP(insertion.maxIP)} ${insertion.host}\n` + - `Intersects with: ${IPTools.numberToIP(next.minIP)}-${IPTools.numberToIP(next.maxIP)} (${next.host})` + `Could not insert: ${IPTools.rangeToString(insertion)} ${insertion.host}\n` + + `Intersects with: ${IPTools.rangeToString(next)} (${next.host})` ); } } @@ -407,14 +419,14 @@ export const IPTools = new class { const prev = sortedRanges[iMin - 1]; if (insertion.minIP >= prev.minIP && insertion.maxIP <= prev.maxIP) { throw new Error( - `Too narrow: ${IPTools.numberToIP(insertion.minIP)}-${IPTools.numberToIP(insertion.maxIP)} (${insertion.host})\n` + - `Intersects with: ${IPTools.numberToIP(prev.minIP)}-${IPTools.numberToIP(prev.maxIP)} (${prev.host})` + `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.numberToIP(insertion.minIP)}-${IPTools.numberToIP(insertion.maxIP)} (${insertion.host})\n` + - `Intersects with: ${IPTools.numberToIP(prev.minIP)}-${IPTools.numberToIP(prev.maxIP)} (${prev.host})` + `Could not insert: ${IPTools.rangeToString(insertion)} (${insertion.host})\n` + + `Intersects with: ${IPTools.rangeToString(prev)} (${prev.host})` ); } } @@ -568,7 +580,7 @@ export const IPTools = new class { * - 'unknown' - no rdns entry, treat with suspicion */ getHostType(host: string, ip: string) { - if (Punishments.sharedIps.has(ip)) { + if (Punishments.isSharedIp(ip)) { return 'shared'; } if (this.singleIPOpenProxies.has(ip) || this.torProxyIps.has(ip)) { diff --git a/server/monitor.ts b/server/monitor.ts index 0e61811bbb..242dff5aff 100644 --- a/server/monitor.ts +++ b/server/monitor.ts @@ -206,7 +206,7 @@ export const Monitor = new class { if (Config.noipchecks || Config.nothrottle) return false; const count = this.battlePreps.increment(ip, 3 * 60 * 1000)[0]; if (count <= 12) return false; - if (count < 120 && Punishments.sharedIps.has(ip)) return false; + if (count < 120 && Punishments.isSharedIp(ip)) return false; connection.popup('Due to high load, you are limited to 12 battles and team validations every 3 minutes.'); return true; } @@ -236,7 +236,7 @@ export const Monitor = new class { if (Config.noipchecks || Config.nothrottle) return false; const [count] = this.netRequests.increment(ip, 1 * 60 * 1000); if (count <= 10) return false; - if (count < 120 && Punishments.sharedIps.has(ip)) return false; + if (count < 120 && Punishments.isSharedIp(ip)) return false; return true; } @@ -246,7 +246,7 @@ export const Monitor = new class { countTickets(ip: string) { if (Config.noipchecks || Config.nothrottle) return false; const count = this.tickets.increment(ip, 60 * 60 * 1000)[0]; - if (Punishments.sharedIps.has(ip)) { + if (Punishments.isSharedIp(ip)) { return count >= 20; } else { return count >= 5; diff --git a/server/punishments.ts b/server/punishments.ts index 35a5ad81b3..c5102ab262 100644 --- a/server/punishments.ts +++ b/server/punishments.ts @@ -12,6 +12,7 @@ */ import {FS, Utils} from '../lib'; +import type {AddressRange} from './ip-tools'; const PUNISHMENT_FILE = 'config/punishments.tsv'; const ROOM_PUNISHMENT_FILE = 'config/room-punishments.tsv'; @@ -228,6 +229,11 @@ export const Punishments = new class { * sharedIps is an ip:note Map */ readonly sharedIps = new Map(); + /** + * AddressRange:note map. In a separate map so we iterate a massive map a lot less. + * (AddressRange is a bit of a premature optimization, but it saves us a conversion call on some trafficked spots) + */ + readonly sharedRanges = new Map(); /** * sharedIpBlacklist is an ip:note Map */ @@ -457,7 +463,15 @@ export const Punishments = new class { for (const row of data.replace('\r', '').split("\n")) { if (!row) continue; const [ip, type, note] = row.trim().split("\t"); - if (!IPTools.ipRegex.test(ip)) continue; + if (!IPTools.ipRegex.test(ip)) { + const pattern = IPTools.stringToRange(ip); + if (pattern) { + Punishments.sharedRanges.set(pattern, note); + } else { + Monitor.adminlog(`Invalid range data in '${SHAREDIPS_FILE}': "${row}".`); + } + continue; + } if (type !== 'SHARED') continue; Punishments.sharedIps.set(ip, note); @@ -465,15 +479,23 @@ export const Punishments = new class { } appendSharedIp(ip: string, note: string) { - const buf = `${ip}\tSHARED\t${note}\r\n`; + const pattern = IPTools.stringToRange(ip); + let ipString = ip; + if (pattern && pattern.minIP !== pattern.maxIP) { + ipString = IPTools.rangeToString(pattern); + } + const buf = `${ipString}\tSHARED\t${note}\r\n`; return FS(SHAREDIPS_FILE).append(buf); } saveSharedIps() { let buf = 'IP\tType\tNote\r\n'; - Punishments.sharedIps.forEach((note, ip) => { + for (const [note, ip] of Punishments.sharedIps) { buf += `${ip}\tSHARED\t${note}\r\n`; - }); + } + for (const [range, note] of Punishments.sharedRanges) { + buf += `${IPTools.rangeToString(range)}\tSHARED\t${note}\r\n`; + } return FS(SHAREDIPS_FILE).write(buf); } @@ -1113,7 +1135,7 @@ export const Punishments = new class { for (const ip of user.ips) { punishment = Punishments.ips.getByType(ip, 'BATTLEBAN'); if (punishment) { - if (Punishments.sharedIps.has(ip) && user.autoconfirmed) return; + if (Punishments.isSharedIp(ip) && user.autoconfirmed) return; return punishment; } } @@ -1178,7 +1200,7 @@ export const Punishments = new class { for (const ip of targetUser.ips) { punishment = Punishments.ips.getByType(ip, 'GROUPCHATBAN'); if (punishment) { - if (Punishments.sharedIps.has(ip) && targetUser.autoconfirmed) return; + if (Punishments.isSharedIp(ip) && targetUser.autoconfirmed) return; return punishment; } } @@ -1196,7 +1218,7 @@ export const Punishments = new class { if (punishment) return punishment; // skip if the user is autoconfirmed and on a shared ip // [0] is forced to be the latestIp - if (Punishments.sharedIps.has(ips[0])) return false; + if (Punishments.isSharedIp(ips[0])) return false; for (const ip of ips) { const curPunishment = Punishments.ips.getByType(ip, 'TICKETBAN'); @@ -1357,11 +1379,20 @@ export const Punishments = new class { } addSharedIp(ip: string, note: string) { - Punishments.sharedIps.set(ip, note); + const pattern = IPTools.stringToRange(ip); + const isRange = pattern && pattern.minIP !== pattern.maxIP; + if (isRange) { + Punishments.sharedRanges.set(pattern, note); + } else { + Punishments.sharedIps.set(ip, note); + } void Punishments.appendSharedIp(ip, note); for (const user of Users.users.values()) { - if (user.locked && user.locked !== user.id && user.ips.includes(ip)) { + const sharedIp = user.ips.some( + curIP => (isRange ? IPTools.checkPattern([pattern], IPTools.ipToNumber(curIP)) : curIP === ip) + ); + if (user.locked && user.locked !== user.id && sharedIp) { if (!user.autoconfirmed) { user.semilocked = `#sharedip ${user.locked}` as PunishType; } @@ -1374,6 +1405,17 @@ export const Punishments = new class { } } + isSharedIp(ip: string) { + if (this.sharedIps.has(ip)) return true; + const num = IPTools.ipToNumber(ip); + for (const range of this.sharedRanges.keys()) { + if (IPTools.checkPattern([range], num)) { + return true; + } + } + return false; + } + removeSharedIp(ip: string) { Punishments.sharedIps.delete(ip); void Punishments.saveSharedIps(); @@ -1552,7 +1594,7 @@ export const Punishments = new class { `` : ''; if (battleban) { - if (battleban.id !== user.id && Punishments.sharedIps.has(user.latestIp) && user.autoconfirmed) { + if (battleban.id !== user.id && Punishments.isSharedIp(user.latestIp) && user.autoconfirmed) { Punishments.unpunish(userid, 'BATTLEBAN'); } else { void Punishments.punish(user, battleban, false); @@ -1582,7 +1624,7 @@ export const Punishments = new class { } const bannedUnder = punishUserid !== userid ? ` because you have the same IP as banned user: ${punishUserid}` : ''; - if ((id === 'LOCK' || id === 'NAMELOCK') && punishUserid !== userid && Punishments.sharedIps.has(user.latestIp)) { + if ((id === 'LOCK' || id === 'NAMELOCK') && punishUserid !== userid && Punishments.isSharedIp(user.latestIp)) { if (!user.autoconfirmed) { user.semilocked = `#sharedip ${user.locked}` as PunishType; } @@ -1636,7 +1678,7 @@ export const Punishments = new class { if (punishments) { let shared = false; for (const punishment of punishments) { - if (Punishments.sharedIps.has(user.latestIp)) { + if (Punishments.isSharedIp(user.latestIp)) { if (!user.locked && !user.autoconfirmed) { user.semilocked = `#sharedip ${punishment.id}` as PunishType; } @@ -1684,7 +1726,7 @@ export const Punishments = new class { return '#cflood'; } - if (Punishments.sharedIps.has(ip)) return false; + if (Punishments.isSharedIp(ip)) return false; let banned: false | string = false; const punishment = Punishments.ipSearch(ip, 'BAN'); @@ -1792,7 +1834,7 @@ export const Punishments = new class { if (punishment.type === 'ROOMBAN') { return punishment; } else if (punishment.type === 'BLACKLIST') { - if (Punishments.sharedIps.has(ip) && user.autoconfirmed) return; + if (Punishments.isSharedIp(ip) && user.autoconfirmed) return; return punishment; } @@ -1815,18 +1857,14 @@ export const Punishments = new class { } isBlacklistedSharedIp(ip: string) { - const num = IPTools.ipToNumber(ip); - if (!num) { - if (IPTools.ipRangeRegex.test(ip)) { - return this.sharedIpBlacklist.has(ip); - } else { - throw new Error(`Invalid IP address: '${ip}'`); - } + const pattern = IPTools.stringToRange(ip); + if (!pattern) { + throw new Error(`Invalid IP address: '${ip}'`); } for (const [blacklisted, reason] of this.sharedIpBlacklist) { const range = IPTools.stringToRange(blacklisted); if (!range) throw new Error("Falsy range in sharedIpBlacklist"); - if (IPTools.checkPattern([range], num)) return reason; + if (IPTools.rangeIntersects(range, pattern)) return reason; } return false; } diff --git a/server/users.ts b/server/users.ts index f19e849a9d..449fc93a6f 100644 --- a/server/users.ts +++ b/server/users.ts @@ -149,7 +149,7 @@ function getExactUser(name: string | User) { */ function findUsers(userids: ID[], ips: string[], options: {forPunishment?: boolean, includeTrusted?: boolean} = {}) { const matches: User[] = []; - if (options.forPunishment) ips = ips.filter(ip => !Punishments.sharedIps.has(ip)); + if (options.forPunishment) ips = ips.filter(ip => !Punishments.isSharedIp(ip)); const ipMatcher = IPTools.checker(ips); for (const user of users.values()) { if (!options.forPunishment && !user.named && !user.connected) continue;