irc-framework
Version:
A better IRC framework for node.js
302 lines (254 loc) • 9.83 kB
JavaScript
'use strict';
/**
* TCP / TLS transport
*/
const net = require('net');
const tls = require('tls');
const EventEmitter = require('events').EventEmitter;
const SocksClient = require('socks').SocksClient;
const iconv = require('iconv-lite');
const SOCK_DISCONNECTED = 0;
const SOCK_CONNECTING = 1;
const SOCK_CONNECTED = 2;
module.exports = class Connection extends EventEmitter {
constructor(options) {
super();
this.options = options || {};
this.socket = null;
this.state = SOCK_DISCONNECTED;
this.last_socket_error = null;
this.socket_events = [];
this.encoding = 'utf8';
}
isConnected() {
return this.state === SOCK_CONNECTED;
}
writeLine(line, cb) {
if (this.socket && this.isConnected()) {
if (this.encoding !== 'utf8') {
this.socket.write(iconv.encode(line + '\r\n', this.encoding), cb);
} else {
this.socket.write(line + '\r\n', cb);
}
} else {
this.debugOut('writeLine() called when not connected');
if (cb) {
process.nextTick(cb);
}
}
}
debugOut(out) {
this.emit('debug', 'NetTransport ' + out);
}
_bindEvent(obj, event, fn) {
obj.on(event, fn);
const unbindEvent = () => {
obj.off(event, fn);
};
this.socket_events.push(unbindEvent);
return unbindEvent;
}
_unbindEvents() {
this.socket_events.forEach(fn => fn());
}
connect() {
const options = this.options;
const ircd_host = options.host;
const ircd_port = options.port || 6667;
let sni;
this.debugOut('connect()');
this.disposeSocket();
this.requested_disconnect = false;
this.incoming_buffer = Buffer.from('');
// Include server name (SNI) if provided host is not an IP address
if (!this.getAddressFamily(ircd_host)) {
sni = ircd_host;
}
if (!options.encoding || !this.setEncoding(options.encoding)) {
this.setEncoding('utf8');
}
this.state = SOCK_CONNECTING;
this.debugOut('Connecting socket..');
if (options.socks) {
this.debugOut('Using SOCKS proxy');
this.socket = null;
SocksClient.createConnection({
proxy: {
host: options.socks.host,
port: options.socks.port || 8080,
type: 5, // Proxy version (4 or 5)
userId: options.socks.user,
password: options.socks.pass,
},
command: 'connect',
destination: {
host: ircd_host,
port: ircd_port,
}
}).then(info => {
let connection = info.socket;
if (options.tls || options.ssl) {
connection = tls.connect({
socket: connection,
servername: sni,
rejectUnauthorized: options.rejectUnauthorized,
key: options.client_certificate && options.client_certificate.private_key,
cert: options.client_certificate && options.client_certificate.certificate,
});
}
this.socket = connection;
this.debugOut('SOCKS connection established.');
this._onSocketCreate(options, connection);
}).catch(this.onSocketError.bind(this));
} else {
let socket = null;
if (options.tls || options.ssl) {
socket = this.socket = tls.connect({
servername: sni,
host: ircd_host,
port: ircd_port,
rejectUnauthorized: options.rejectUnauthorized,
key: options.client_certificate && options.client_certificate.private_key,
cert: options.client_certificate && options.client_certificate.certificate,
localAddress: options.outgoing_addr,
family: this.getAddressFamily(options.outgoing_addr)
});
} else {
socket = this.socket = net.connect({
host: ircd_host,
port: ircd_port,
localAddress: options.outgoing_addr,
family: this.getAddressFamily(options.outgoing_addr)
});
}
this._onSocketCreate(options, socket);
}
}
_onSocketCreate(options, socket) {
this.debugOut('Socket created!');
if (options.ping_interval > 0 && options.ping_timeout > 0) {
socket.setTimeout((options.ping_interval + options.ping_timeout) * 1000);
}
// We need the raw socket connect event.
// It seems SOCKS gives us the socket in an already open state! Deal with that:
if (socket.readyState !== 'opening') {
this.onSocketRawConnected();
if (!(socket instanceof tls.TLSSocket)) {
this.onSocketFullyConnected();
}
} else {
this._bindEvent(socket, 'connect', this.onSocketRawConnected.bind(this));
}
this._bindEvent(
socket,
socket instanceof tls.TLSSocket ? 'secureConnect' : 'connect',
this.onSocketFullyConnected.bind(this)
);
this._bindEvent(socket, 'close', this.onSocketClose.bind(this));
this._bindEvent(socket, 'error', this.onSocketError.bind(this));
this._bindEvent(socket, 'data', this.onSocketData.bind(this));
this._bindEvent(socket, 'timeout', this.onSocketTimeout.bind(this));
}
// Called when the socket is connected and before any TLS handshaking if applicable.
// This is when it's ideal to read socket pairs for identd.
onSocketRawConnected() {
this.debugOut('socketRawConnected()');
this.state = SOCK_CONNECTED;
this.emit('extra', 'raw socket connected', (this.socket.socket || this.socket));
}
// Called when the socket is connected and ready to start sending/receiving data.
onSocketFullyConnected() {
this.debugOut('socketFullyConnected()');
this.last_socket_error = null;
this.emit('open');
}
onSocketClose() {
this.debugOut('socketClose()');
this.state = SOCK_DISCONNECTED;
this.emit('close', this.last_socket_error ? this.last_socket_error : false);
}
onSocketError(err) {
this.debugOut('socketError() ' + err.message);
this.last_socket_error = err;
// this.emit('error', err);
}
onSocketTimeout() {
this.debugOut('socketTimeout()');
this.close(true);
}
onSocketData(data) {
// Buffer incoming data because multiple messages can arrive at once
// without necessarily ending in a new line
this.incoming_buffer = Buffer.concat(
[this.incoming_buffer, data],
this.incoming_buffer.length + data.length
);
let startIndex = 0;
while (true) {
// Search for the next new line in the buffered data
const endIndex = this.incoming_buffer.indexOf(0x0A, startIndex) + 1;
// If this message is partial, keep it in the buffer until more data arrives.
// If startIndex is equal to incoming_buffer.length, that means we reached the end
// of the buffer and it ended on a new line, slice will return an empty buffer.
if (endIndex === 0) {
this.incoming_buffer = this.incoming_buffer.slice(startIndex);
break;
}
// Slice a single message delimited by a new line, decode it and emit it out
let line = this.incoming_buffer.slice(startIndex, endIndex);
line = iconv.decode(line, this.encoding);
this.emit('line', line);
startIndex = endIndex;
}
}
disposeSocket() {
this.debugOut('disposeSocket() connected=' + this.isConnected());
if (this.socket && this.state !== SOCK_DISCONNECTED) {
this.socket.destroy();
}
if (this.socket) {
this._unbindEvents();
this.socket = null;
}
}
close(force) {
if (!this.socket) {
this.debugOut('close() called with no socket');
return;
}
// Cleanly close the socket if we can
if (this.state === SOCK_CONNECTING || force) {
this.debugOut('close() destroying');
this.socket.destroy();
} else if (this.state === SOCK_CONNECTED) {
this.debugOut('close() ending');
this.socket.end();
}
}
setEncoding(encoding) {
let encoded_test;
this.debugOut('Connection.setEncoding() encoding=' + encoding);
try {
const testString = 'TEST\r\ntest';
encoded_test = iconv.encode(testString, 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.toString('ascii') === testString) {
this.encoding = encoding;
return true;
}
return false;
} catch (err) {
return false;
}
}
getAddressFamily(addr) {
if (net.isIPv4(addr)) {
return 4;
}
if (net.isIPv6(addr)) {
return 6;
}
}
};