diff --git a/zefie_wtvp_minisrv/includes/classes/WTVIRC.js b/zefie_wtvp_minisrv/includes/classes/WTVIRC.js index 86fa9c3d..03ad02c4 100644 --- a/zefie_wtvp_minisrv/includes/classes/WTVIRC.js +++ b/zefie_wtvp_minisrv/includes/classes/WTVIRC.js @@ -7,12 +7,24 @@ const WTVShared = require('./WTVShared.js').WTVShared; class WTVIRC { /* - * WTVIRC - A simple IRC server implementation for WebTV + * @constructor + * @class WTVIRC + * WTVIRC - A small IRC server implementation for WebTV * Tested with WebTV and KvIRC * This is a basic implementation and does not cover all IRC features. + * Supports unencrypted and encrypted (SSL) connections on the same port. * It supports basic commands like NICK, USER, JOIN, PART, PRIVMSG, NOTICE, TOPIC, AWAY, MODE, KICK, and PING. - * TODO: Validate and fix (if needed) ALL existing functionality. Then maybe add more stuff. - * TODO: Masks (ban, invite, exempt, etc.) are not properly functional yet. + * Basic IRCOp functionality is included, you can basically be an channel operator in every channel, or /kill users. + * Channel modes are supported, including invite-only, topic protection, password protection, and user modes (op/voice). + * SSL only channel mode +z is supported. As is usermode +Z (no DMs from non-SSL users) + * + * TODO: k-line? probably not, but maybe in a different format. + * TODO: Test for crashes with arbitrary data, or malformed commands (especially SSL handshake). + * + * @param {Object} minisrv_config - The configuration object for minisrv. + * @param {string} [host='localhost'] - The host to bind the IRC server to. + * @param {number} [port=6667] - The port to bind the IRC server to. + * @param {boolean} [debug=false] - Whether to enable debug mode for logging. */ constructor(minisrv_config, host = 'localhost', port = 6667, debug = false) { this.minisrv_config = minisrv_config; @@ -62,10 +74,11 @@ class WTVIRC { this.kicklen = this.irc_config.kick_len || 255; this.awaylen = this.irc_config.away_len || 200; this.enable_ssl = this.irc_config.enable_ssl || false; + this.maxtargets = this.irc_config.max_targets || 4; this.clientpeak = 0; this.caps = [ `AWAYLEN=${this.awaylen} CASEMAPPING=rfc1459 CHANMODES=beI,k,l,itmnpz CHANNELLEN=${this.channellen} CHANTYPES=${this.channelprefixes.join('')} PREFIX=(ov)@+ USERMODES=oxiws MAXLIST=b:${this.maxbans},e:${this.maxexcept},i:${this.maxinvite},k:${this.maxkeylen},l:${this.maxlimit}`, - `CHARSET=ascii EXCEPTS=e INVEX=I CHANLIMIT=${this.channelprefixes.join('')}:${this.channellimit} NICKLEN=${this.nicklen} TOPICLEN=${this.topiclen} KICKLEN=${this.kicklen}` + `CHARSET=ascii MODES=3 EXCEPTS=e INVEX=I CHANLIMIT=${this.channelprefixes.join('')}:${this.channellimit} NICKLEN=${this.nicklen} TOPICLEN=${this.topiclen} KICKLEN=${this.kicklen}` ]; } @@ -446,6 +459,16 @@ class WTVIRC { } else if (mode.startsWith('-s')) { this.usermodes.set(socket.nickname, (usermodes).filter(m => m !== 's')); socket.write(`:${socket.nickname}!${socket.username}@${socket.host} MODE ${socket.nickname} -s\r\n`); + } else if (mode.startsWith('+z') || mode.startsWith('-z')) { + socket.write(`:${this.servername} 472 ${socket.nickname} ${mode.slice(1)} :is set by the server and cannot be changed\r\n`); + } else if (mode.startsWith('+Z')) { + this.usermodes.set(socket.nickname, [...usermodes, 'Z']); + socket.write(`:${socket.nickname}!${socket.username}@${socket.host} MODE ${socket.nickname} +Z\r\n`); + } else if (mode.startsWith('-Z')) { + this.usermodes.set(socket.nickname, (usermodes).filter(m => m !== 'Z')); + socket.write(`:${socket.nickname}!${socket.username}@${socket.host} MODE ${socket.nickname} -Z\r\n`); + } else { + socket.write(`:${this.servername} 472 ${socket.nickname} ${mode.slice(1)} :is unknown mode char to me\r\n`); } } break; @@ -490,7 +513,7 @@ class WTVIRC { socket.write(`:${this.servername} 431 * :No nickname\r\n`); break; } - if (new_nickname.length > 30) { + if (new_nickname.length > this.nicklen) { socket.write(`:${this.servername} 432 * ${new_nickname} :Erroneus nickname\r\n`); break; } @@ -601,6 +624,10 @@ class WTVIRC { joinLine = `JOIN ${ch}`; } // Simulate a JOIN command for each channel + if (this.getChannelCount(socket.nickname) >= this.channellimit) { + socket.write(`:${this.servername} 405 ${socket.nickname} ${ch} :Too many channels\r\n`); + continue; // Skip joining this channel + } const [command, ...params] = joinLine.trim().split(' '); var validChannel = false; this.channelprefixes.forEach(prefix => { @@ -612,6 +639,10 @@ class WTVIRC { socket.write(`:${this.servername} 403 ${socket.nickname} ${ch} :No such channel\r\n`); continue; // Skip this channel } + if (ch.length < 2 || ch.length > this.channellen) { + socket.write(`:${this.servername} 403 ${socket.nickname} ${ch} :No such channel\r\n`); + continue; // Skip this channel + } if (!this.channels.has(ch)) { this.createChannel(ch, socket.nickname); } @@ -626,6 +657,7 @@ class WTVIRC { if (!modes || modes === true) { continue; // Skip if no modes are set } + // Check if the user is in too many channels const keyMode = modes.find(m => typeof m === 'string' && m.startsWith('k ')); if (keyMode) { const channelKey = keyMode.split(' ')[1]; @@ -668,11 +700,6 @@ class WTVIRC { } } } - // Check if the user is in too many channels - if (this.getChannelCount(socket.nickname) >= this.channellimit) { - socket.write(`:${this.servername} 405 ${socket.nickname} ${ch} :Too many channels\r\n`); - continue; // Skip joining this channel - } // Check if the channel user limit has been reached if (this.channelmodes.has(ch) && this.channelmodes.get(ch).includes('l')) { const limitMatch = this.channelmodes.get(ch).match(/l(\d+)/); @@ -899,54 +926,65 @@ class WTVIRC { this.usertimestamps.set(socket.nickname, Date.now()); if (params[0]) { const target = params[0]; - isChannel = false; - this.channelprefixes.forEach(prefix => { - if (target.startsWith(prefix)) { - isChannel = true; - } - }); - if (isChannel) { - // Channel message - if (this.channelmodes.has(target) && this.channelmodes.get(target).includes('m')) { - // Channel is moderated (+m) - var voices = this.channelvoices.get(target) || new Set(); - var ops = this.channelops.get(target) || new Set(); - if (voices === true) voices = new Set(); - if (ops === true) ops = new Set(); - if (!(voices.has(socket.nickname) || ops.has(socket.nickname))) { - socket.write(`:${this.servername} 404 ${socket.nickname} ${target} :Cannot send to channel (+m)\r\n`); - break; - } - } - if (this.channelmodes.has(target) && this.channelmodes.get(target).includes('n')) { - // Channel is no-external-messages (+n) - if (!this.channels.has(target) || !this.channels.get(target).has(socket.nickname)) { - socket.write(`:${this.servername} 404 ${socket.nickname} ${target} :Cannot send to channel (+n)\r\n`); - break; - } - } + let targets = target.includes(',') ? target.split(',') : [target]; + if (targets.length > this.maxtargets) { + socket.write(`:${this.servername} 407 ${socket.nickname} :Too many targets. Maximum allowed is ${this.maxtargets}\r\n`); + return; } - const msg = line.slice(line.indexOf(':', 1) + 1); - if (isChannel) { - if (!this.channels.has(target)) { - socket.write(`:${this.servername} 403 ${socket.nickname} ${target} :No such channel\r\n`); - break; + for (const t of targets) { + let isChan = false; + for (const prefix of this.channelprefixes) { + if (t.startsWith(prefix)) { + isChan = true; + break; + } } - this.broadcastChannel(target, `:${socket.nickname}!${socket.username}@${socket.host} PRIVMSG ${target} :${msg}\r\n`, socket); - break; - } else { - if (this.awaymsgs.has(target)) { - socket.write(`:${this.servername} 301 ${socket.nickname} ${target} :${this.awaymsgs.get(target)}\r\n`); - } - const targetSock = Array.from(this.nicknames.keys()).find(s => this.nicknames.get(s) === target); - if (!targetSock) { - socket.write(`:${this.servername} 401 ${socket.nickname} ${target} :No such nick/channel\r\n`); - return; + if (isChan) { + // Channel message + if (this.channelmodes.has(t) && this.channelmodes.get(t).includes('m')) { + // Channel is moderated (+m) + var voices = this.channelvoices.get(t) || new Set(); + var ops = this.channelops.get(t) || new Set(); + if (voices === true) voices = new Set(); + if (ops === true) ops = new Set(); + if (!(voices.has(socket.nickname) || ops.has(socket.nickname))) { + socket.write(`:${this.servername} 404 ${socket.nickname} ${t} :Cannot send to channel (+m)\r\n`); + continue; + } + } + if (this.channelmodes.has(t) && this.channelmodes.get(t).includes('n')) { + // Channel is no-external-messages (+n) + if (!this.channels.has(t) || !this.channels.get(t).has(socket.nickname)) { + socket.write(`:${this.servername} 404 ${socket.nickname} ${t} :Cannot send to channel (+n)\r\n`); + continue; + } + } } const msg = line.slice(line.indexOf(':', 1) + 1); - targetSock.write(`:${socket.nickname}!${socket.username}@${socket.host} PRIVMSG ${target} :${msg}\r\n`); - break; + if (isChan) { + if (!this.channels.has(t)) { + socket.write(`:${this.servername} 403 ${socket.nickname} ${t} :No such channel\r\n`); + continue; + } + this.broadcastChannel(t, `:${socket.nickname}!${socket.username}@${socket.host} PRIVMSG ${t} :${msg}\r\n`, socket); + } else { + if (this.awaymsgs.has(t)) { + socket.write(`:${this.servername} 301 ${socket.nickname} ${t} :${this.awaymsgs.get(t)}\r\n`); + } + const targetSock = Array.from(this.nicknames.keys()).find(s => this.nicknames.get(s) === t); + if (!targetSock) { + socket.write(`:${this.servername} 401 ${socket.nickname} ${t} :No such nick/channel\r\n`); + continue; + } + const targetUserModes = this.usermodes.get(t) || []; + if (targetUserModes.includes('Z') && !socket.secure) { + socket.write(`:${this.servername} 484 ${socket.nickname} ${t} :Cannot send to user (+Z)\r\n`); + continue; + } + targetSock.write(`:${socket.nickname}!${socket.username}@${socket.host} PRIVMSG ${t} :${msg}\r\n`); + } } + return; } break; case 'NOTICE': @@ -956,37 +994,45 @@ class WTVIRC { } this.usertimestamps.set(socket.nickname, Date.now()); if (params[0]) { - const msg = line.slice(line.indexOf(':', 1) + 1); - let validTarget = false; - for (const prefix of this.channelprefixes) { - if (params[0].startsWith(prefix)) { - validTarget = true; - break; - } + const target = params[0]; + let targets = target.includes(',') ? target.split(',') : [target]; + if (targets.length > this.maxtargets) { + socket.write(`:${this.servername} 407 ${socket.nickname} :Too many targets. Maximum allowed is ${this.maxtargets}\r\n`); + return; } - if (validTarget) { - if (this.channelmodes.has(target) && this.channelmodes.get(target).includes('n')) { - // Channel is no-external-messages (+n) - if (!this.channels.has(target) || !this.channels.get(target).has(socket.nickname)) { - socket.write(`:${this.servername} 404 ${socket.nickname} ${target} :Cannot send to channel (+n)\r\n`); + for (const t of targets) { + let isChan = false; + for (const prefix of this.channelprefixes) { + if (t.startsWith(prefix)) { + isChan = true; break; } } - if (!this.channels.has(params[0])) { - socket.write(`:${this.servername} 403 ${socket.nickname} ${params[0]} :No such channel\r\n`); - break; + if (isChan) { + // Channel notice + if (this.channelmodes.has(t) && this.channelmodes.get(t).includes('n')) { + // Channel is no-external-messages (+n) + if (!this.channels.has(t) || !this.channels.get(t).has(socket.nickname)) { + socket.write(`:${this.servername} 404 ${socket.nickname} ${t} :Cannot send to channel (+n)\r\n`); + continue; + } + } + if (!this.channels.has(t)) { + socket.write(`:${this.servername} 403 ${socket.nickname} ${t} :No such channel\r\n`); + continue; + } + this.broadcastChannel(t, `:${socket.nickname}!${socket.username}@${socket.host} NOTICE ${t} :${msg}\r\n`, socket); + } else { + // Assume it's a nick, check if it exists + const targetSock = Array.from(this.nicknames.keys()).find(s => this.nicknames.get(s) === t); + if (!targetSock) { + socket.write(`:${this.servername} 401 ${socket.nickname} ${t} :No such nick/channel\r\n`); + continue; + } + targetSock.write(`:${socket.nickname}!${socket.username}@${socket.host} NOTICE ${t} :${msg}\r\n`); } - this.broadcastChannel(params[0], `:${socket.nickname}!${socket.username}@${socket.host} NOTICE ${params[0]} :${msg}\r\n`, socket); - break; - } else { - // Assume it's a nick, check if it exists - const targetSock = Array.from(this.nicknames.keys()).find(s => this.nicknames.get(s) === params[0]); - if (!targetSock) { - socket.write(`:${this.servername} 401 ${socket.nickname} ${params[0]} :No such nick/channel\r\n`); - return; - } - targetSock.write(`:${socket.nickname}!${socket.username}@${socket.host} NOTICE ${params[0]} :${msg}\r\n`); - } + } + return; } break; case 'PING': @@ -1528,6 +1574,10 @@ class WTVIRC { if (!this.inviteexceptions.has(channel)) { this.inviteexceptions.set(channel, new Set()); } + if (this.inviteexceptions.get(channel).length >= this.maxinvite) { + socket.write(`:${this.servername} 478 ${nickname} ${channel} :Too many invite exceptions\r\n`); + return; + } this.inviteexceptions.get(channel).add(inviteMask); socket.write(`:${this.servername} 346 ${nickname} ${channel} ${inviteMask}\r\n`); this.broadcastChannel(channel, `:${nickname}!${username}@${socket.host} MODE ${channel} +I ${inviteMask}\r\n`, socket); @@ -1589,6 +1639,10 @@ class WTVIRC { return; } const key = params[2]; + if (key.length < 1 || key.length > this.max_keylen) { + socket.write(`:${this.servername} 501 ${nickname} :Invalid channel key\r\n`); + return; + } var chan_modes = this.channelmodes.get(channel); if (!chan_modes || chan_modes === true) { chan_modes = []; @@ -1597,10 +1651,6 @@ class WTVIRC { this.broadcastChannel(channel, `:${nickname}!${username}@${socket.host} MODE ${channel} +k ${key}\r\n`); return; } else if (mode.startsWith('-k')) { - if (params.length < 2) { - socket.write(`:${this.servername} 461 ${nickname} MODE :Not enough parameters\r\n`); - return; - } var chan_modes = this.channelmodes.get(channel); if (!chan_modes || chan_modes === true) { chan_modes = []; @@ -1669,6 +1719,10 @@ class WTVIRC { if (!this.channelbans.has(channel)) { this.channelbans.set(channel, new Set()); } + if (this.channelbans.get(channel).length >= this.maxbans) { + socket.write(`:${this.servername} 478 ${nickname} ${channel} :Channel ban list is full\r\n`); + return; + } this.channelbans.get(channel).add(banMask); socket.write(`:${this.servername} 367 ${nickname} ${channel} ${banMask}\r\n`); this.broadcastChannel(channel, `:${nickname}!${username}@${socket.host} MODE ${channel} +b ${banMask}\r\n`, socket); @@ -1697,6 +1751,10 @@ class WTVIRC { if (!this.channelexemptions.has(channel)) { this.channelexemptions.set(channel, new Set()); } + if (this.channelexemptions.get(channel).size >= this.maxexemptions) { + socket.write(`:${this.servername} 478 ${nickname} ${channel} :Channel exemption list is full\r\n`); + return; + } this.channelexemptions.get(channel).add(exemptMask); socket.write(`:${this.servername} 347 ${nickname} ${channel} ${exemptMask}\r\n`); this.broadcastChannel(channel, `:${nickname}!${username}@${socket.host} MODE ${channel} +e ${exemptMask}\r\n`, socket); @@ -1824,7 +1882,7 @@ class WTVIRC { socket.write(`:${this.servername} 337 ${nickname} ${channel} :End of channel invite list\r\n`); return; } else { - socket.write(`:${this.servername} 501 ${nickname} :Unknown MODE flag\r\n`); + socket.write(`:${this.servername} 472 ${nickname} ${mode} :is unknown mode char to me\r\n`); return; } } @@ -1871,23 +1929,18 @@ class WTVIRC { if (!usermodes.includes(mode)) { usermodes.push(mode); this.usermodes.set(nickname, usermodes); - socket.write(`:${nickname}!${nickname}@${this.getHostname(socket)} MODE ${nickname} +${mode}\r\n`); - if (mode === 'x') { - socket.host = this.getHostname(socket); - socket.write(`:${this.servername} 396 ${nickname} ${socket.host} :is now your displayed host\r\n`); - } } } if (socket.secure) { - var usermodes = this.usermodes.get(nickname); if (!usermodes || usermodes === true) { usermodes = []; } - usermodes.push('s'); + usermodes.push('z'); this.usermodes.set(nickname, usermodes); - socket.write(`:${nickname}!${nickname}@${this.getHostname(socket)} MODE ${nickname} +s\r\n`); } + socket.write(`:${this.servername} 221 ${nickname} :+${this.usermodes.get(nickname).join('')}\r\n`); + socket.write(`:${this.servername} 396 ${nickname} ${socket.host} :is now your displayed host\r\n`); } }