/**
* Chat plugin to help manage hosts, proxies, and datacenters
* Written by Annika
* Original /adddatacenters command written by Zarel
*/
import { Utils } from "../../lib";
import type { AddressRange } from "../ip-tools";
import type { GlobalPermission } from "../user-groups";
const HOST_SUFFIXES = ['res', 'proxy', 'mobile'];
const SUFFIX_ALIASES: { [k: string]: string } = {
residential: 'res',
};
const WHITELISTED_USERIDS: ID[] = [];
function checkCanPerform(
context: Chat.PageContext | Chat.CommandContext, user: User, permission: GlobalPermission = 'lockdown'
) {
if (!WHITELISTED_USERIDS.includes(user.id)) context.checkCan(permission);
}
function getHostType(type: string) {
type = toID(type);
if (HOST_SUFFIXES.includes(type)) return type;
if (SUFFIX_ALIASES[type]) return SUFFIX_ALIASES[type];
throw new Chat.ErrorMessage(`'${type}' is not a valid host type. Please specify one of ${HOST_SUFFIXES.join(', ')}.`);
}
export function visualizeRangeList(ranges: AddressRange[]) {
let html = `
Lowest IP address | Highest IP address | Host |
`;
for (const range of ranges) {
html += ``;
html += `${IPTools.numberToIP(range.minIP)} | `;
html += `${IPTools.numberToIP(range.maxIP)} | `;
html += Utils.html`${range.host} | `;
html += `
`;
}
return html;
}
function formatRange(range: AddressRange, includeModlogBrackets?: boolean) {
const startBracket = includeModlogBrackets ? '[' : '';
const endBracket = includeModlogBrackets ? ']' : '';
let result = `${startBracket}${IPTools.numberToIP(range.minIP)}${endBracket}`;
result += `-${startBracket}${IPTools.numberToIP(range.maxIP)}${endBracket}`;
if (range.host) result += ` (${range.host})`;
return result;
}
export const pages: Chat.PageTable = {
proxies(query, user) {
this.title = "[Proxies]";
checkCanPerform(this, user, 'globalban');
const openProxies = [...IPTools.singleIPOpenProxies];
const proxyHosts = [...IPTools.proxyHosts];
Utils.sortBy(openProxies, ip => {
const number = IPTools.ipToNumber(ip);
if (number === null) {
Rooms.get('upperstaff')?.add(`|error|Invalid IP address in IPTools.singleIPOpenProxies: '${ip}'`);
return -1;
}
return number;
});
proxyHosts.sort();
IPTools.sortRanges();
let html = `Single IP proxies:
IP | Type |
`;
for (const proxyIP of openProxies) {
html += `${proxyIP} | Single IP open proxy |
`;
}
html += `
`;
html += `Proxy hosts:
Host | Type |
`;
for (const proxyHost of proxyHosts) {
html += `${proxyHost} | Proxy host |
`;
}
html += `
`;
html += `Proxy IP Ranges:
`;
html += visualizeRangeList(IPTools.ranges.filter(r => r.host?.endsWith('/proxy')));
html += `
`;
return html;
},
hosts(query, user) {
this.title = "[Hosts]";
checkCanPerform(this, user, 'globalban');
const type = toID(query[0]) || 'all';
IPTools.sortRanges();
const mobileHosts = ['all', 'mobile'].includes(type) ? [...IPTools.mobileHosts] : [];
const residentialHosts = ['all', 'residential', 'res'].includes(type) ? [...IPTools.residentialHosts] : [];
const hostRanges = ['all', 'ranges', 'isps'].includes(type) ?
IPTools.ranges.filter(r => r.host && !r.host.endsWith('/proxy')) :
[];
mobileHosts.sort();
residentialHosts.sort();
let html = `Mobile hosts:
Host | Type |
`;
for (const mobileHost of mobileHosts) {
html += `${mobileHost} | Mobile host |
`;
}
html += `
`;
html += `Residential hosts:
Host | Type |
`;
for (const resHost of residentialHosts) {
html += `${resHost} | Residential host |
`;
}
html += `
`;
html += `ISP IP Ranges:
`;
html += visualizeRangeList(hostRanges);
html += `
`;
return html;
},
ranges(query, user) {
this.title = "[IP Ranges]";
checkCanPerform(this, user, 'globalban');
const type = toID(query[0]) || 'all';
IPTools.sortRanges();
let html = ``;
if (['all', 'mobile'].includes(type)) {
html += `Mobile IP Ranges:
`;
html += visualizeRangeList(IPTools.ranges.filter(range => range.host?.endsWith('/mobile')));
html += `
`;
}
if (['all', 'res', 'residential'].includes(type)) {
html += `Residential IP Ranges:
`;
html += visualizeRangeList(IPTools.ranges.filter(range => range.host?.endsWith('/res')));
html += `
`;
}
if (['all', 'proxy', 'proxies'].includes(type)) {
html += `Proxy IP Ranges:
`;
html += visualizeRangeList(IPTools.ranges.filter(range => range.host?.endsWith('/proxy')));
html += `
`;
}
if (['all', 'openproxy'].includes(type)) {
html += `Single-IP Open Proxies:
`;
for (const ip of IPTools.singleIPOpenProxies) {
html += `${ip} |
`;
}
html += `
`;
}
return html;
},
sharedipblacklist(args, user, connection) {
this.title = `[Shared IP Blacklist]`;
checkCanPerform(this, user, 'lock');
let buf = `IPs blocked from being marked as shared
`;
if (!Punishments.sharedIpBlacklist.size) {
buf += `
None currently.
`;
} else {
buf += `
IP | Reason |
`;
const sortedSharedIPBlacklist = [...Punishments.sharedIpBlacklist];
Utils.sortBy(sortedSharedIPBlacklist, ([ipOrRange]) => {
if (IPTools.ipRegex.test(ipOrRange)) {
const number = IPTools.ipToNumber(ipOrRange);
if (number === null) {
Monitor.error(`Invalid blacklisted-from-markshared IP: '${ipOrRange}`);
return -1;
}
return number;
}
return IPTools.stringToRange(ipOrRange)!.minIP;
});
for (const [ip, reason] of sortedSharedIPBlacklist) {
buf += `${ip} | ${reason} |
`;
}
buf += `
`;
}
buf += `
`;
return buf;
},
sharedips(args, user, connection) {
this.title = `[Shared IPs]`;
checkCanPerform(this, user, 'globalban');
let buf = `IPs marked as shared
`;
if (!Punishments.sharedIps.size) {
buf += `
None currently.
`;
} else {
buf += `
IP | Location |
`;
const sortedSharedIPs = [...Punishments.sharedIps];
Utils.sortBy(sortedSharedIPs, ([ip]) => {
const number = IPTools.ipToNumber(ip);
if (number === null) {
Monitor.error(`Invalid shared IP address: '${ip}'`);
return -1;
}
return number;
});
for (const [ip, location] of sortedSharedIPs) {
buf += `${ip} | ${location} |
`;
}
buf += `
`;
}
buf += `
`;
return buf;
},
};
export const commands: Chat.ChatCommands = {
dc: 'ipranges',
datacenter: 'ipranges',
datacenters: 'ipranges',
iprange: 'ipranges',
ipranges: {
'': 'help',
help() {
return this.parse('/help ipranges');
},
show: 'view',
view(target, room, user) {
checkCanPerform(this, user, 'globalban');
const types = ['all', 'residential', 'res', 'mobile', 'proxy', 'openproxy'];
const type = target ? toID(target) : 'all';
if (!types.includes(type)) {
return this.errorReply(`'${type}' isn't a valid host type. Specify one of ${types.join(', ')}.`);
}
return this.parse(`/join view-ranges-${type}`);
},
viewhelp: [
`/ipranges view - View the list of all IP ranges. Requires: hosts manager @ ~`,
`/ipranges view [type] - View the list of a particular type of IP range ('residential', 'mobile', or 'proxy'). Requires: hosts manager @ ~`,
],
// Originally by Zarel
widen: 'add',
add(target, room, user, connection, cmd) {
checkCanPerform(this, user, 'globalban');
if (!target) return this.parse('/help ipranges add');
// should be in the format: IP, IP, name, URL
const widen = cmd.includes('widen');
const [typeString, stringRange, host] = target.split(',').map(part => part.trim());
if (!host || !IPTools.hostRegex.test(host)) {
return this.errorReply(`Invalid data: ${target}`);
}
const type = getHostType(typeString);
const range = IPTools.stringToRange(stringRange);
if (!range) return this.errorReply(`Couldn't parse IP range '${stringRange}'.`);
range.host = `${IPTools.urlToHost(host)}?/${type}`;
IPTools.sortRanges();
let result;
try {
result = IPTools.checkRangeConflicts(range, IPTools.ranges, widen);
} catch (e: any) {
return this.errorReply(e.message);
}
if (typeof result === 'number') {
// Remove the range that is being widened
IPTools.removeRange(IPTools.ranges[result].minIP, IPTools.ranges[result].maxIP);
}
IPTools.addRange(range as AddressRange & { host: string });
this.privateGlobalModAction(`${user.name} added the IP range ${formatRange(range)} to the list of ${type} ranges.`);
this.globalModlog('IPRANGE ADD', null, formatRange(range, true));
},
addhelp: [
`/ipranges add [type], [low]-[high], [host] - Adds an IP range. Requires: hosts manager ~`,
`/ipranges widen [type], [low]-[high], [host] - Adds an IP range, allowing a new range to completely cover an old range. Requires: hosts manager ~`,
`For example: /ipranges add proxy, 5.152.192.0 - 5.152.223.255, redstation.com`,
`Get datacenter info from whois; [low], [high] are the range in the last inetnum; [type] is one of res, proxy, or mobile.`,
],
remove(target, room, user) {
checkCanPerform(this, user);
if (!target) return this.parse('/help ipranges remove');
const range = IPTools.stringToRange(target);
if (!range) return this.errorReply(`Couldn't parse the IP range '${target}'.`);
if (!IPTools.getRange(range.minIP, range.maxIP)) return this.errorReply(`No IP range found at '${target}'.`);
void IPTools.removeRange(range.minIP, range.maxIP);
this.privateGlobalModAction(`${user.name} removed the IP range ${formatRange(range)}.`);
this.globalModlog('IPRANGE REMOVE', null, formatRange(range, true));
},
removehelp: [
`/ipranges remove [low IP]-[high IP] - Removes an IP range. Requires: hosts manager ~`,
`Example: /ipranges remove 5.152.192.0-5.152.223.255`,
],
rename(target, room, user) {
checkCanPerform(this, user);
if (!target) return this.parse('/help ipranges rename');
const [type, rangeString, url] = target.split(',').map(part => part.trim());
if (!url) {
return this.parse('/help ipranges rename');
}
const toRename = IPTools.stringToRange(rangeString);
if (!toRename) return this.errorReply(`Couldn't parse IP range '${rangeString}'.`);
const exists = IPTools.getRange(toRename.minIP, toRename.maxIP);
if (!exists) return this.errorReply(`No IP range found at '${rangeString}'.`);
const range = {
minIP: toRename.minIP,
maxIP: toRename.maxIP,
host: `${IPTools.urlToHost(url)}?/${type}`,
};
void IPTools.addRange(range);
this.privateGlobalModAction(`${user.name} renamed the IP range ${formatRange(toRename)} to ${range.host}.`);
this.globalModlog('IPRANGE RENAME', null, `IP range ${formatRange(toRename, true)} to ${range.host}`);
},
renamehelp: [
`/ipranges rename [type], [low IP]-[high IP], [host] - Changes the host an IP range resolves to. Requires: hosts manager ~`,
],
},
iprangeshelp() {
const help = [
`/ipranges view [type]
: view the list of a particular type of IP range (residential
, mobile
, or proxy
). Requires: hosts manager @ ~`,
`/ipranges add [type], [low IP]-[high IP], [host]
: add IP ranges (can be multiline). Requires: hosts manager ~/ipranges view
: view the list of all IP ranges. Requires: hosts manager @ ~`,
`/ipranges widen [type], [low IP]-[high IP], [host]
: add IP ranges, allowing a new range to completely cover an old range. Requires: hosts manager ~`,
`For example: /ipranges add proxy, 5.152.192.0-5.152.223.255, redstation.com
.`,
`Get datacenter info from /whois
; [low IP]
, [high IP]
are the range in the last inetnum.`,
`/ipranges remove [low IP]-[high IP]
: remove IP range(s). Can be multiline. Requires: hosts manager ~`,
`For example: /ipranges remove 5.152.192.0, 5.152.223.255
.`,
`/ipranges rename [type], [low IP]-[high IP], [host]
: changes the host an IP range resolves to. Requires: hosts manager ~`,
];
return this.sendReply(`|html|${help.join('
')}`);
},
viewhosts(target, room, user) {
checkCanPerform(this, user, 'globalban');
const types = ['all', 'residential', 'mobile', 'ranges'];
const type = target ? toID(target) : 'all';
if (!types.includes(type)) {
return this.errorReply(`'${type}' isn't a valid host type. Specify one of ${types.join(', ')}.`);
}
return this.parse(`/join view-hosts-${type}`);
},
viewhostshelp: [
`/viewhosts - View the list of hosts. Requires: hosts manager @ ~`,
`/viewhosts [type] - View the list of a particular type of host. Requires: hosts manager @ ~`,
`Host types are: 'all', 'residential', 'mobile', and 'ranges'.`,
],
removehost: 'addhosts',
removehosts: 'addhosts',
addhost: 'addhosts',
addhosts(target, room, user, connection, cmd) {
checkCanPerform(this, user);
const removing = cmd.includes('remove');
let [type, ...hosts] = target.split(',');
type = toID(type);
hosts = hosts.map(host => host.trim());
if (!hosts.length) return this.parse('/help addhosts');
switch (type) {
case 'openproxy':
for (const host of hosts) {
if (!IPTools.ipRegex.test(host)) return this.errorReply(`'${host}' is not a valid IP address.`);
if (removing !== IPTools.singleIPOpenProxies.has(host)) {
return this.errorReply(`'${host}' is ${removing ? 'not' : 'already'} in the list of proxy IPs.`);
}
}
if (removing) {
void IPTools.removeOpenProxies(hosts);
} else {
void IPTools.addOpenProxies(hosts);
}
break;
case 'proxy':
for (const host of hosts) {
if (!IPTools.hostRegex.test(host)) return this.errorReply(`'${host}' is not a valid host.`);
if (removing !== IPTools.proxyHosts.has(host)) {
return this.errorReply(`'${host}' is ${removing ? 'not' : 'already'} in the list of proxy hosts.`);
}
}
if (removing) {
void IPTools.removeProxyHosts(hosts);
} else {
void IPTools.addProxyHosts(hosts);
}
break;
case 'residential':
for (const host of hosts) {
if (!IPTools.hostRegex.test(host)) return this.errorReply(`'${host}' is not a valid host.`);
if (removing !== IPTools.residentialHosts.has(host)) {
return this.errorReply(`'${host}' is ${removing ? 'not' : 'already'} in the list of residential hosts.`);
}
}
if (removing) {
void IPTools.removeResidentialHosts(hosts);
} else {
void IPTools.addResidentialHosts(hosts);
}
break;
case 'mobile':
for (const host of hosts) {
if (!IPTools.hostRegex.test(host)) return this.errorReply(`'${host}' is not a valid host.`);
if (removing !== IPTools.mobileHosts.has(host)) {
return this.errorReply(`'${host}' is ${removing ? 'not' : 'already'} in the list of mobile hosts.`);
}
}
if (removing) {
void IPTools.removeMobileHosts(hosts);
} else {
void IPTools.addMobileHosts(hosts);
}
break;
default:
return this.errorReply(`'${type}' isn't one of 'openproxy', 'proxy', 'residential', or 'mobile'.`);
}
this.privateGlobalModAction(
`${user.name} ${removing ? 'removed' : 'added'} ${hosts.length} hosts (${hosts.join(', ')}) ${removing ? 'from' : 'to'} the ${type} category`
);
this.globalModlog(removing ? 'REMOVEHOSTS' : 'ADDHOSTS', null, `${type}: ${hosts.join(', ')}`);
},
addhostshelp: [
`/addhosts [category], host1, host2, ... - Adds hosts to the given category. Requires: hosts manager ~`,
`/removehosts [category], host1, host2, ... - Removes hosts from the given category. Requires: hosts manager ~`,
`Categories are: 'openproxy' (which takes IP addresses, not hosts), 'proxy', 'residential', and 'mobile'.`,
],
viewproxies(target, room, user) {
checkCanPerform(this, user, 'globalban');
return this.parse('/join view-proxies');
},
viewproxieshelp: [
`/viewproxies - View the list of proxies. Requires: hosts manager @ ~`,
],
markshared(target, room, user) {
if (!target) return this.parse('/help markshared');
checkCanPerform(this, user, 'globalban');
const [ip, note] = this.splitOne(target);
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.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.`);
}
if (!note) {
this.errorReply(`You must specify who owns this shared IP.`);
this.parse(`/help markshared`);
return;
}
Punishments.addSharedIp(ip, note);
this.privateGlobalModAction(`The IP '${ip}' was marked as shared by ${user.name}. (${note})`);
this.globalModlog('SHAREDIP', null, note, ip);
},
marksharedhelp: [
`/markshared [IP], [owner/organization of IP] - Marks an IP address as shared.`,
`Note: the owner/organization (i.e., University of Minnesota) of the shared IP is required. Requires @ ~`,
],
unmarkshared(target, room, user) {
if (!target) return this.parse('/help unmarkshared');
checkCanPerform(this, user, 'globalban');
target = target.trim();
const pattern = IPTools.stringToRange(target);
if (!pattern) return this.errorReply("Please enter a valid IP address.");
if (pattern.minIP !== pattern.maxIP && !user.can('rangeban')) {
return this.errorReply(`Only administrators can unmarkshare ranges.`);
}
let shared = false;
if (pattern.minIP !== pattern.maxIP) {
for (const range of Punishments.sharedRanges.keys()) {
shared = range.minIP === pattern.minIP && range.maxIP === pattern.maxIP;
if (shared) break;
}
} else {
shared = Punishments.sharedIps.has(target);
}
if (!shared) return this.errorReply(`That IP/range isn't marked as shared.`);
Punishments.removeSharedIp(target);
this.privateGlobalModAction(`The IP '${target}' was unmarked as shared by ${user.name}.`);
this.globalModlog('UNSHAREDIP', null, null, target);
},
unmarksharedhelp: [`/unmarkshared [IP] - Unmarks a shared IP address. Requires @ ~`],
marksharedblacklist: 'nomarkshared',
marksharedbl: 'nomarkshared',
nomarkshared: {
add(target, room, user) {
if (!target) return this.parse(`/help nomarkshared`);
checkCanPerform(this, user, 'globalban');
const [ip, ...reasonArr] = target.split(',');
if (!IPTools.ipRangeRegex.test(ip)) return this.errorReply(`Please enter a valid IP address or range.`);
if (!reasonArr?.length) {
this.errorReply(`A reason is required.`);
this.parse(`/help nomarkshared`);
return;
}
if (Punishments.isBlacklistedSharedIp(ip)) {
return this.errorReply(`This IP is already blacklisted from being marked as shared.`);
}
// this works because we test ipRangeRegex above, which works for both ranges AND single ips.
// so we know here this is one of the two.
// If it doesn't work as a single IP, it's a range.
if (!IPTools.ipRegex.test(ip)) {
// if it doesn't end with *, it doesn't function as a range in IPTools#stringToRange, only as a single IP.
// that's valid behavior, but it's detrimental here.
if (!ip.endsWith('*')) {
this.errorReply(`That looks like a range, but it is invalid.`);
this.errorReply(`Append * to the end of the range and try again.`);
return;
}
if (!user.can('bypassall')) {
return this.errorReply(`Only Administrators can add ranges.`);
}
const range = IPTools.stringToRange(ip);
if (!range) return this.errorReply(`Invalid IP range.`);
for (const sharedIp of Punishments.sharedIps.keys()) {
const ipNum = IPTools.ipToNumber(sharedIp);
if (IPTools.checkPattern([range], ipNum)) {
this.parse(`/unmarkshared ${sharedIp}`);
}
}
} else {
if (Punishments.isSharedIp(ip)) this.parse(`/unmarkshared ${ip}`);
}
const reason = reasonArr.join(',');
Punishments.addBlacklistedSharedIp(ip, reason);
this.privateGlobalModAction(`The IP '${ip}' was blacklisted from being marked as shared by ${user.name}.`);
this.globalModlog('SHAREDIP BLACKLIST', null, reason.trim(), ip);
},
remove(target, room, user) {
if (!target) return this.parse(`/help nomarkshared`);
checkCanPerform(this, user);
if (!IPTools.ipRangeRegex.test(target)) return this.errorReply(`Please enter a valid IP address or range.`);
if (!Punishments.sharedIpBlacklist.has(target)) {
return this.errorReply(`This IP is not blacklisted from being marked as shared.`);
}
Punishments.removeBlacklistedSharedIp(target);
this.privateGlobalModAction(`The IP '${target}' was unblacklisted from being marked as shared by ${user.name}.`);
this.globalModlog('SHAREDIP UNBLACKLIST', null, null, target);
},
view() {
return this.parse(`/join view-sharedipblacklist`);
},
help: '',
''() {
return this.parse(`/help nomarkshared`);
},
},
nomarksharedhelp: [
`/nomarkshared add [IP], [reason] - Prevents an IP from being marked as shared until it's removed from this list. Requires ~`,
`Note: Reasons are required.`,
`/nomarkshared remove [IP] - Removes an IP from the nomarkshared list. Requires ~`,
`/nomarkshared view - Lists all IPs prevented from being marked as shared. Requires @ ~`,
],
sharedips: 'viewsharedips',
viewsharedips() {
return this.parse('/join view-sharedips');
},
viewsharedipshelp: [
`/viewsharedips — Lists IP addresses marked as shared. Requires: hosts manager @ ~`,
],
};