X-Git-Url: https://vcs.fsf.org/?a=blobdiff_plain;f=server%2Firc%2Fconnection.js;h=8b1d3ba72a0f7fb24ff54c2cfb3f5d2e93843088;hb=8b967a9961ec1b44ffaf94989da085cb0d650ec1;hp=6c567e404a0eb4ae8c40643b6cd111a89299f9ab;hpb=cefa09007ec2b6a0777188df73ead6366cb05a0b;p=KiwiIRC.git diff --git a/server/irc/connection.js b/server/irc/connection.js index 6c567e4..8b1d3ba 100644 --- a/server/irc/connection.js +++ b/server/irc/connection.js @@ -1,23 +1,51 @@ var net = require('net'), tls = require('tls'), util = require('util'), + dns = require('dns'), _ = require('lodash'), - EventEmitter2 = require('eventemitter2').EventEmitter2, + EventBinder = require('./eventbinder.js'), IrcServer = require('./server.js'), + IrcCommands = require('./commands.js'), IrcChannel = require('./channel.js'), - IrcUser = require('./user.js'); + IrcUser = require('./user.js'), + EE = require('../ee.js'), + iconv = require('iconv-lite'), + Socks; -var IrcConnection = function (hostname, port, ssl, nick, user, pass, state) { +// Break the Node.js version down into usable parts +var version_values = process.version.substr(1).split('.').map(function (item) { + return parseInt(item, 10); +}); + +// If we have a suitable Nodejs version, bring int he socks functionality +if (version_values[1] >= 10) { + Socks = require('socksjs'); +} + +var IrcConnection = function (hostname, port, ssl, nick, user, options, state, con_num) { var that = this; - EventEmitter2.call(this,{ + + EE.call(this,{ wildcard: true, - delimiter: ':' + delimiter: ' ' }); - + this.setMaxListeners(0); + + options = options || {}; + // Socket state this.connected = false; + // IRCd write buffers (flood controll) + this.write_buffer = []; + + // In process of writing the buffer? + this.writing_buffer = false; + + // Max number of lines to write a second + this.write_buffer_lines_second = 2; + // If registeration with the IRCd has completed this.registered = false; @@ -27,75 +55,88 @@ var IrcConnection = function (hostname, port, ssl, nick, user, pass, state) { // User information this.nick = nick; this.user = user; // Contains users real hostname and address - this.username = this.nick.replace(/[^0-9a-zA-Z\-_.]/, ''); - this.password = pass; - + this.username = this.nick.replace(/[^0-9a-zA-Z\-_.\/]/, ''); + this.password = options.password || ''; + + // Set the passed encoding. or the default if none giving or it fails + if (!options.encoding || !this.setEncoding(options.encoding)) { + this.setEncoding(global.config.default_encoding); + } + // State object this.state = state; - + + // Connection ID in the state + this.con_num = con_num; + + // IRC protocol handling + this.irc_commands = new IrcCommands(this); + // IrcServer object this.server = new IrcServer(this, hostname, port); - + // IrcUser objects this.irc_users = Object.create(null); - + + // TODO: use `this.nick` instead of `'*'` when using an IrcUser per nick + this.irc_users[this.nick] = new IrcUser(this, '*'); + // IrcChannel objects this.irc_channels = Object.create(null); - - // Create IrcUser and IrcChannel objects when needed - // TODO: Remove IrcUser objects when they are no longer needed - this.on('server:*:connect', function (event) { - that.nick = event.nick; - that.irc_users[event.nick] = new IrcUser(that, event.nick); - }); - this.on('channel:*:join', function (event) { - var chan; - if (event.nick === that.nick) { - chan = new IrcChannel(that, event.channel); - that.irc_channels[event.channel] = chan; - chan.irc_events.join.call(chan, event); - } - }); - - this.on('user:*:privmsg', function (event) { - var user; - if (event.channel === that.nick) { - if (!that.irc_users[event.nick]) { - user = new IrcUser(that, event.nick); - that.irc_users[event.nick] = user; - user.irc_events.privmsg.call(user, event); - } - } - }); // IRC connection information this.irc_host = {hostname: hostname, port: port}; this.ssl = !(!ssl); + // SOCKS proxy details + // TODO: Wildcard matching of hostnames and/or CIDR ranges of IP addresses + if ((global.config.socks_proxy && global.config.socks_proxy.enabled) && ((global.config.socks_proxy.all) || (_.contains(global.config.socks_proxy.proxy_hosts, this.irc_host.hostname)))) { + this.socks = { + host: global.config.socks_proxy.address, + port: global.config.socks_proxy.port, + user: global.config.socks_proxy.user, + pass: global.config.socks_proxy.pass + }; + } else { + this.socks = false; + } + // Options sent by the IRCd this.options = Object.create(null); this.cap = {requested: [], enabled: []}; // Is SASL supported on the IRCd this.sasl = false; - + // Buffers for data sent from the IRCd this.hold_last = false; - this.held_data = ''; + this.held_data = null; - - // Call any modules before making the connection - global.modules.emit('irc:connecting', {connection: this}) - .done(function () { - that.connect(); - }); + this.applyIrcEvents(); }; -util.inherits(IrcConnection, EventEmitter2); +util.inherits(IrcConnection, EE); module.exports.IrcConnection = IrcConnection; +IrcConnection.prototype.applyIrcEvents = function () { + // Listen for events on the IRC connection + this.irc_events = { + 'server * connect': onServerConnect, + 'channel * join': onChannelJoin, + + // TODO: uncomment when using an IrcUser per nick + //'user:*:privmsg': onUserPrivmsg, + 'user * nick': onUserNick, + 'channel * part': onUserParts, + 'channel * quit': onUserParts, + 'channel * kick': onUserKick + }; + + EventBinder.bindIrcEvents('', this.irc_events, this, this); +}; + /** * Start the connection to the IRCd @@ -106,49 +147,122 @@ IrcConnection.prototype.connect = function () { // The socket connect event to listener for var socket_connect_event_name = 'connect'; + // The destination address + var dest_addr = this.socks ? + this.socks.host : + this.irc_host.hostname; // Make sure we don't already have an open connection this.disposeSocket(); - // Open either a secure or plain text socket - if (this.ssl) { - this.socket = tls.connect({ - host: this.irc_host.hostname, - port: this.irc_host.port, - rejectUnauthorized: global.config.reject_unauthorised_certificates - }); + // Get the IP family for the dest_addr (either socks or IRCd destination) + getConnectionFamily(dest_addr, function getConnectionFamilyCb(err, family, host) { + var outgoing; + + // Decide which net. interface to make the connection through + if (global.config.outgoing_address) { + if ((family === 'IPv6') && (global.config.outgoing_address.IPv6)) { + outgoing = global.config.outgoing_address.IPv6; + } else { + outgoing = global.config.outgoing_address.IPv4 || '0.0.0.0'; + + // We don't have an IPv6 interface but dest_addr may still resolve to + // an IPv4 address. Reset `host` and try connecting anyway, letting it + // fail if an IPv4 resolved address is not found + host = dest_addr; + } - socket_connect_event_name = 'secureConnect'; + // If we have an array of interfaces, select a random one + if (typeof outgoing !== 'string' && outgoing.length) { + outgoing = outgoing[Math.floor(Math.random() * outgoing.length)]; + } - } else { - this.socket = net.connect({ - host: this.irc_host.hostname, - port: this.irc_host.port + // Make sure we have a valid interface address + if (typeof outgoing !== 'string') + outgoing = '0.0.0.0'; + + } else { + // No config was found so use the default + outgoing = '0.0.0.0'; + } + + // Are we connecting through a SOCKS proxy? + if (this.socks) { + that.socket = Socks.connect({ + host: host, + port: that.irc_host.port, + ssl: that.ssl, + rejectUnauthorized: global.config.reject_unauthorised_certificates + }, {host: that.socks.host, + port: that.socks.port, + user: that.socks.user, + pass: that.socks.pass, + localAddress: outgoing + }); + + } else { + // No socks connection, connect directly to the IRCd + + if (that.ssl) { + that.socket = tls.connect({ + host: host, + port: that.irc_host.port, + rejectUnauthorized: global.config.reject_unauthorised_certificates, + localAddress: outgoing + }); + + // We need the raw socket connect event + that.socket.socket.on('connect', function() { rawSocketConnect.call(that, this); }); + + socket_connect_event_name = 'secureConnect'; + + } else { + that.socket = net.connect({ + host: host, + port: that.irc_host.port, + localAddress: outgoing + }); + } + } + + // Apply the socket listeners + that.socket.on(socket_connect_event_name, function socketConnectCb() { + + // TLS connections have the actual socket as a property + var is_tls = (typeof this.socket !== 'undefined') ? + true : + false; + + // TLS sockets have already called this + if (!is_tls) + rawSocketConnect.call(that, this); + + that.connected = true; + + socketConnectHandler.call(that); + }); + + that.socket.on('error', function socketErrorCb(event) { + that.emit('error', event); }); - } - this.socket.setEncoding('utf-8'); + that.socket.on('data', function () { + socketOnData.apply(that, arguments); + }); - this.socket.on(socket_connect_event_name, function () { - that.connected = true; - socketConnectHandler.apply(that, arguments); - }); + that.socket.on('close', function socketCloseCb(had_error) { + that.connected = false; - this.socket.on('error', function (event) { - that.emit('error', event); + // Remove this socket form the identd lookup + if (that.identd_port_pair) { + delete global.clients.port_pairs[that.identd_port_pair]; + } - }); - - this.socket.on('data', function () { - parse.apply(that, arguments); - }); - - this.socket.on('close', function (had_error) { - that.connected = false; - that.emit('close'); + that.emit('close'); - // Close the whole socket down - that.disposeSocket(); + // Close the whole socket down + that.disposeSocket(); + }); }); }; @@ -162,20 +276,84 @@ IrcConnection.prototype.clientEvent = function (event_name, data, callback) { /** * Write a line of data to the IRCd + * @param data The line of data to be sent + * @param force Write the data now, ignoring any write queue + */ +IrcConnection.prototype.write = function (data, force) { + //ENCODE string to encoding of the server + encoded_buffer = iconv.encode(data + '\r\n', this.encoding); + + if (force) { + this.socket.write(encoded_buffer); + return; + } + + this.write_buffer.push(encoded_buffer); + + // Only flush if we're not writing already + if (!this.writing_buffer) + this.flushWriteBuffer(); +}; + + + +/** + * Flush the write buffer to the server in a throttled fashion */ -IrcConnection.prototype.write = function (data, callback) { - this.socket.write(data + '\r\n', 'utf-8', callback); +IrcConnection.prototype.flushWriteBuffer = function () { + + // In case the socket closed between writing our queue.. clean up + if (!this.connected) { + this.write_buffer = []; + this.writing_buffer = false; + return; + } + + this.writing_buffer = true; + + // Disabled write buffer? Send everything we have + if (!this.write_buffer_lines_second) { + this.write_buffer.forEach(function(buffer, idx) { + this.socket.write(buffer); + this.write_buffer = null; + }); + + this.write_buffer = []; + this.writing_buffer = false; + + return; + } + + // Nothing to write? Stop writing and leave + if (this.write_buffer.length === 0) { + this.writing_buffer = false; + return; + } + + this.socket.write(this.write_buffer[0]); + this.write_buffer = this.write_buffer.slice(1); + + // Call this function again at some point if we still have data to write + if (this.write_buffer.length > 0) { + setTimeout(this.flushWriteBuffer.bind(this), 1000 / this.write_buffer_lines_second); + } else { + // No more buffers to write.. so we've finished + this.writing_buffer = false; + } }; /** - * Close the connection to the IRCd after sending one last line + * Close the connection to the IRCd after forcing one last line */ IrcConnection.prototype.end = function (data, callback) { + if (!this.socket) + return; + if (data) - this.write(data); - + this.write(data, true); + this.socket.end(); }; @@ -185,15 +363,33 @@ IrcConnection.prototype.end = function (data, callback) { * Clean up this IrcConnection instance and any sockets */ IrcConnection.prototype.dispose = function () { + // If we're still connected, wait until the socket is closed before disposing + // so that all the events are still correctly triggered + if (this.socket && this.connected) { + this.end(); + return; + } + + if (this.socket) { + this.disposeSocket(); + } + _.each(this.irc_users, function (user) { user.dispose(); }); _.each(this.irc_channels, function (chan) { chan.dispose(); }); - this.irc_users = null; - this.irc_channels = null; - this.disposeSocket(); + this.irc_users = undefined; + this.irc_channels = undefined; + + this.server.dispose(); + this.server = undefined; + + this.irc_commands = undefined; + + EventBinder.unbindIrcEvents('', this.irc_events, this); + this.removeAllListeners(); }; @@ -204,11 +400,147 @@ IrcConnection.prototype.dispose = function () { */ IrcConnection.prototype.disposeSocket = function () { if (this.socket) { + this.socket.end(); this.socket.removeAllListeners(); this.socket = null; } }; +/** + * Set a new encoding for this connection + * Return true in case of success + */ + +IrcConnection.prototype.setEncoding = function (encoding) { + var encoded_test; + + try { + encoded_test = iconv.encode("TEST", encoding); + //This test is done to check if this encoding also supports + //the ASCII charset required by the IRC protocols + //(Avoid the use of base64 or incompatible encodings) + if (encoded_test == "TEST") { + this.encoding = encoding; + return true; + } + return false; + } catch (err) { + return false; + } +}; + +function getConnectionFamily(host, callback) { + if (net.isIP(host)) { + if (net.isIPv4(host)) { + callback(null, 'IPv4', host); + } else { + callback(null, 'IPv6', host); + } + } else { + dns.resolve6(host, function resolve6Cb(err, addresses) { + if (!err) { + callback(null, 'IPv6', addresses[0]); + } else { + dns.resolve4(host, function resolve4Cb(err, addresses) { + if (!err) { + callback(null, 'IPv4',addresses[0]); + } else { + callback(err); + } + }); + } + }); + } +} + + +function onChannelJoin(event) { + var chan; + + // Only deal with ourselves joining a channel + if (event.nick !== this.nick) + return; + + // We should only ever get a JOIN command for a channel + // we're not already a member of.. but check we don't + // have this channel in case something went wrong somewhere + // at an earlier point + if (!this.irc_channels[event.channel]) { + chan = new IrcChannel(this, event.channel); + this.irc_channels[event.channel] = chan; + chan.irc_events.join.call(chan, event); + } +} + + +function onServerConnect(event) { + this.nick = event.nick; +} + + +function onUserPrivmsg(event) { + var user; + + // Only deal with messages targetted to us + if (event.channel !== this.nick) + return; + + if (!this.irc_users[event.nick]) { + user = new IrcUser(this, event.nick); + this.irc_users[event.nick] = user; + user.irc_events.privmsg.call(user, event); + } +} + + +function onUserNick(event) { + var user; + + // Only deal with messages targetted to us + if (event.nick !== this.nick) + return; + + this.nick = event.newnick; +} + + +function onUserParts(event) { + // Only deal with ourselves leaving a channel + if (event.nick !== this.nick) + return; + + if (this.irc_channels[event.channel]) { + this.irc_channels[event.channel].dispose(); + delete this.irc_channels[event.channel]; + } +} + +function onUserKick(event){ + // Only deal with ourselves being kicked from a channel + if (event.kicked !== this.nick) + return; + + if (this.irc_channels[event.channel]) { + this.irc_channels[event.channel].dispose(); + delete this.irc_channels[event.channel]; + } + +} + + + +/** + * When a socket connects to an IRCd + * May be called before any socket handshake are complete (eg. TLS) + */ +var rawSocketConnect = function(socket) { + // Make note of the port numbers for any identd lookups + // Nodejs < 0.9.6 has no socket.localPort so check this first + if (typeof socket.localPort != 'undefined') { + this.identd_port_pair = socket.localPort.toString() + '_' + socket.remotePort.toString(); + global.clients.port_pairs[this.identd_port_pair] = this; + } +}; /** @@ -229,7 +561,7 @@ var socketConnectHandler = function () { // Let the webirc/etc detection modify any required parameters connect_data = findWebIrc.call(this, connect_data); - global.modules.emit('irc:authorize', connect_data).done(function () { + global.modules.emit('irc authorize', connect_data).done(function ircAuthorizeCb() { // Send any initial data for webirc/etc if (connect_data.prepend_data) { _.each(connect_data.prepend_data, function(data) { @@ -241,10 +573,10 @@ var socketConnectHandler = function () { if (that.password) that.write('PASS ' + that.password); - + that.write('NICK ' + that.nick); that.write('USER ' + that.username + ' 0 0 :' + '[www.kiwiirc.com] ' + that.nick); - + that.emit('connected'); }); }; @@ -274,8 +606,14 @@ function findWebIrc(connect_data) { // Check if we need to pass the users IP as its username/ident if (ip_as_username && ip_as_username.indexOf(this.irc_host.hostname) > -1) { // Get a hex value of the clients IP - this.username = this.user.address.split('.').map(function(i, idx){ - return parseInt(i, 10).toString(16); + this.username = this.user.address.split('.').map(function ipSplitMapCb(i, idx){ + var hex = parseInt(i, 10).toString(16); + + // Pad out the hex value if it's a single char + if (hex.length === 1) + hex = '0' + hex; + + return hex; }).join(''); } @@ -284,74 +622,125 @@ function findWebIrc(connect_data) { } - /** - * The regex that parses a line of data from the IRCd - * Deviates from the RFC a little to support the '/' character now used in some - * IRCds + * Buffer any data we get from the IRCd until we have complete lines. */ -var parse_regex = /^(?:(?:(?:(@[^ ]+) )?):(?:([a-z0-9\x5B-\x60\x7B-\x7D\.\-]+)|([a-z0-9\x5B-\x60\x7B-\x7D\.\-]+)!([a-z0-9~\.\-_|]+)@?([a-z0-9\.\-:\/_]+)?) )?(\S+)(?: (?!:)(.+?))?(?: :(.+))?$/i; - -var parse = function (data) { - var i, - msg, - msg2, - trm, - j, - tags = [], - tag; - - if (this.hold_last && this.held_data !== '') { - data = this.held_data + data; +function socketOnData(data) { + var data_pos, // Current position within the data Buffer + line_start = 0, + lines = [], + line = '', + max_buffer_size = 1024; // 1024 bytes is the maximum length of two RFC1459 IRC messages. + // May need tweaking when IRCv3 message tags are more widespread + + // Split data chunk into individual lines + for (data_pos = 0; data_pos < data.length; data_pos++) { + if (data[data_pos] === 0x0A) { // Check if byte is a line feed + lines.push(data.slice(line_start, data_pos)); + line_start = data_pos + 1; + } + } + + // No complete lines of data? Check to see if buffering the data would exceed the max buffer size + if (!lines[0]) { + if ((this.held_data ? this.held_data.length : 0 ) + data.length > max_buffer_size) { + // Buffering this data would exeed our max buffer size + this.emit('error', 'Message buffer too large'); + this.socket.destroy(); + + } else { + + // Append the incomplete line to our held_data and wait for more + if (this.held_data) { + this.held_data = Buffer.concat([this.held_data, data], this.held_data.length + data.length); + } else { + this.held_data = data; + } + } + + // No complete lines to process.. + return; + } + + // If we have an incomplete line held from the previous chunk of data + // merge it with the first line from this chunk of data + if (this.hold_last && this.held_data !== null) { + lines[0] = Buffer.concat([this.held_data, lines[0]], this.held_data.length + lines[0].length); this.hold_last = false; - this.held_data = ''; + this.held_data = null; } - // If the last line is incomplete, hold it until we have more data - if (data.substr(-1) !== '\n') { + // If the last line of data in this chunk is not complete, hold it so + // it can be merged with the first line from the next chunk + if (line_start < data_pos) { + if ((data.length - line_start) > max_buffer_size) { + // Buffering this data would exeed our max buffer size + this.emit('error', 'Message buffer too large'); + this.socket.destroy(); + return; + } + this.hold_last = true; + this.held_data = new Buffer(data.length - line_start); + data.copy(this.held_data, 0, line_start); } // Process our data line by line - data = data.split("\n"); - for (i = 0; i < data.length; i++) { - if (!data[i]) break; - - // If flagged to hold the last line, store it and move on - if (this.hold_last && (i === data.length - 1)) { - this.held_data = data[i]; - break; - } + for (i = 0; i < lines.length; i++) + parseIrcLine.call(this, lines[i]); - // Parse the complete line, removing any carriage returns - msg = parse_regex.exec(data[i].replace(/^\r+|\r+$/, '')); +} - if (msg) { - if (msg[1]) { - tags = msg[1].split(';'); - for (j = 0; j < tags.length; j++) { - tag = tags[j].split('='); - tags[j] = {tag: tag[0], value: tag[1]}; - } - } - msg = { - tags: tags, - prefix: msg[2], - nick: msg[3], - ident: msg[4], - hostname: msg[5] || '', - command: msg[6], - params: msg[7] || '', - trailing: (msg[8]) ? msg[8].trim() : '' - }; - msg.params = msg.params.split(' '); - - this.emit('irc_' + msg.command.toUpperCase(), msg); - } else { - // The line was not parsed correctly, must be malformed - console.log("Malformed IRC line: " + data[i].replace(/^\r+|\r+$/, '')); +/** + * The regex that parses a line of data from the IRCd + * Deviates from the RFC a little to support the '/' character now used in some + * IRCds + */ +var parse_regex = /^(?:(?:(?:(@[^ ]+) )?):(?:([a-z0-9\x5B-\x60\x7B-\x7D\.\-*]+)|([a-z0-9\x5B-\x60\x7B-\x7D\.\-*]+)!([^\x00\r\n\ ]+?)@?([a-z0-9\.\-:\/_]+)?) )?(\S+)(?: (?!:)(.+?))?(?: :(.+))?$/i; + +function parseIrcLine(buffer_line) { + var msg, + i, j, + tags = [], + tag, + line = ''; + + // Decode server encoding + line = iconv.decode(buffer_line, this.encoding); + if (!line) return; + + // Parse the complete line, removing any carriage returns + msg = parse_regex.exec(line.replace(/^\r+|\r+$/, '')); + + if (!msg) { + // The line was not parsed correctly, must be malformed + console.log("Malformed IRC line: " + line.replace(/^\r+|\r+$/, '')); + return; + } + + // Extract any tags (msg[1]) + if (msg[1]) { + tags = msg[1].split(';'); + + for (j = 0; j < tags.length; j++) { + tag = tags[j].split('='); + tags[j] = {tag: tag[0], value: tag[1]}; } } -}; + + msg = { + tags: tags, + prefix: msg[2], + nick: msg[3], + ident: msg[4], + hostname: msg[5] || '', + command: msg[6], + params: msg[7] || '', + trailing: (msg[8]) ? msg[8].trim() : '' + }; + + msg.params = msg.params.split(' '); + this.irc_commands.dispatch(msg.command.toUpperCase(), msg); +}