UNPKG

@nodertc/sctp

Version:

SCTP network protocol (RFC4960) in plain js

494 lines (423 loc) 13.4 kB
/* RFC 6458 Sockets API Extensions for the Stream Control Transmission Protocol (SCTP) */ const Duplex = require('stream').Duplex const Readable = require('stream').Readable const Writable = require('stream').Writable const EventEmitter = require('events').EventEmitter const debug = require('debug') const ip = require('ip') const Endpoint = require('./endpoint') class SCTPStreamReadable extends Readable { // Constructor is useless constructor(socket, streamId) { super() this.socket = socket this.stream_id = streamId this.debugger = this.socket.debugger } _read() { this.debugger.debug('_read stream') } } class SCTPStreamWritable extends Writable { constructor(socket, streamId) { super() this.socket = socket this.debugger = this.socket.debugger this.stream_id = streamId this.bytesWritten = 0 } _write(chunk, encoding, callback) { this.debugger.debug('> write stream %d, %d bytes', this.stream_id, chunk.length) if (!this.socket.association) { return callback(new Error('no association established')) } const options = {} options.stream_id = this.stream_id this.bytesWritten += chunk.length this.socket.bytesWritten += chunk.length return this.socket.association.SEND(chunk, options, callback) } } class Socket extends Duplex { constructor(options) { super(options) options = options || {} this.ootb = options.ootb this.debugger = {} this.debugger.info = debug('sctp:sockets:##') this.debugger.info('starting socket %o', options) this.writeCount = 0 this.bytesRead = 0 this.bytesWritten = 0 /* Todo? this.bufferSize = 0 // getter of this.writeBuffer.length? this.destroyed = false this.connecting = false this._highWaterMark = 8 * 1024 this.writeBuffer = [] */ this.streamsReadable = [] this.streamsWritable = [] this.stream_id = options.stream_id || false this.unordered = options.unordered || false this.no_bundle = options.no_bundle || false this.ppid = options.ppid || 0 } _read() { this.debugger.debug('_read') // This function means that socket wants to get more data // should exist even if empty } createStream(streamId) { if (streamId < 0 || streamId >= this.OS) { /* After the association is initialized, the valid outbound stream identifier range for either endpoint shall be 0 to min(local OS, remote MIS)-1. */ this.debugger.warn('wrong stream %d, OS: %d, MIS: %d', streamId, this.OS, this.MIS) throw new Error('wrong stream id, check local OS and peer MIS') } this.debugger.warn('createStream %d, OS: %d, MIS: %d', streamId, this.OS, this.MIS) if (this.streamsWritable[streamId]) { return this.streamsWritable[streamId] } const stream = new SCTPStreamWritable(this, streamId) this.streamsWritable[streamId] = stream return stream } _write(chunk, encoding, callback) { const writeCount = this.writeCount++ this.debugger.info('> write socket #%d %d bytes', writeCount, chunk.length) if (!this.association) { return callback(new Error('no association established')) } const options = {} options.stream_id = this.stream_id this.bytesWritten += chunk.length // While a stream is not draining, calls to write() will buffer chunk, and return false. // internal _send let drain = this.association.SEND(chunk, options, (error) => { setImmediate(callback) this.debugger.debug('> write socket #%d complete', writeCount) if (error) { this.debugger.warn('> write socket error', error) } }) this.debugger.trace('draining', drain) return drain } _final(callback) { /* This optional function will be called before the stream closes, delaying the 'finish' event until callback is called. This is useful to close resources or write buffered data before a stream ends. */ // called by end() // todo! this.debugger.info('_final') if (this.association) { this.association.SHUTDOWN(callback) } } address() { return { port: this.localPort, address: this.localAddress, family: 'IPv4' } } connect(options, connectListener) { /* Port: Port the client should connect to (Required). host: Host the client should connect to. Defaults to 'localhost'. localAddress: Local interface to bind to for network connections. localPort: Local port to bind to for network connections. family : Version of IP stack. Defaults to 4. hints: dns.lookup() hints. Defaults to 0. lookup : Custom lookup function. Defaults to dns.lookup. */ if (this.outbound) { return } this.outbound = true if (typeof options !== 'object') { options = {port: options} } this.passive = Boolean(options.passive) options.port = ~~options.port if (isPort(options.port)) { this.remotePort = options.port } else { throw new Error('Invalid remote port') } this.remoteAddress = options.host || null // Do not set default host in passive mode, let user decide who may connect if (!this.remoteAddress && !this.passive) { this.remoteAddress = 'localhost' } this.localPort = ~~options.localPort if (isPort(options.localPort)) { this.localPort = options.localPort } else { throw new Error('Invalid source port') } this.localAddress = toarray(options.localAddress) this.udpTransport = options.transport if (this.udpTransport) { this.localAddress = undefined this.remoteAddress = undefined } this.debugger.info( 'connect(%d -> %s:%d)', this.localPort, this.remoteAddress, this.remotePort ) if (typeof connectListener === 'function') { this.once('connect', connectListener) } const assocOptions = { streams: 1, // Todo remoteAddress: this.remoteAddress, remotePort: this.remotePort } const initOptions = { localAddress: this.localAddress, localPort: this.localPort, MIS: options.MIS, OS: options.OS, udpTransport: this.udpTransport, ootb: this.ootb } Endpoint.INITIALIZE(initOptions, (error, endpoint) => { if (error) { this.emit('error', error) } else if (this.passive) { endpoint.on('association', association => { this.debugger.info('associated with %s:%d', association.remoteAddress, association.remotePort) if ( association.remotePort === this.remotePort && association.remoteAddress === this.remoteAddress ) { this.establish(endpoint, association) } else { // Todo abort immediately or even ignore this.debugger.info('denied connect from %d', association.remotePort) association.ABORT() } }) } else { const association = endpoint.ASSOCIATE(assocOptions) this.establish(endpoint, association) } }) } establish(endpoint, association) { this.endpoint = endpoint this.localPort = endpoint.localPort this.localAddress = endpoint.localAddress this.association = association this.remoteAddress = association.remoteAddress this.remotePort = association.remotePort // Update to min(local OS, remote MIS) this.MIS = association.MIS this.OS = association.OS this.remoteFamily = 'IPv4' const label = `${this.localPort}/${this.remoteAddress}:${this.remotePort}` this.debugger.warn = debug(`sctp:sockets:### ${label}`) this.debugger.info = debug(`sctp:sockets:## ${label}`) this.debugger.debug = debug(`sctp:sockets:# ${label}`) this.debugger.trace = debug(`sctp:sockets: ${label}`) // A) association.on('DATA ARRIVE', streamId => { const buffer = association.RECEIVE(streamId) if (!buffer) { return } this.debugger.debug('< DATA ARRIVE %d bytes on stream %d', buffer.length, streamId) if (this.listenerCount('stream') > 0) { if (!this.streamsReadable[streamId]) { this.streamsReadable[streamId] = new SCTPStreamReadable(this, streamId) this.emit('stream', this.streamsReadable[streamId], streamId) } this.streamsReadable[streamId].push(buffer) } this.bytesRead += buffer.length this.push(buffer) }) // B) todo ? association.on('SEND FAILURE', info => { this.debugger.warn('send falure', info) }) // C) todo ? association.on('NETWORK STATUS CHANGE', info => { this.debugger.warn('status change', info) }) association.once('COMMUNICATION UP', () => { this.debugger.info('socket connected') this.emit('connect') }) association.once('COMMUNICATION LOST', (event, reason) => { this.debugger.info('COMMUNICATION LOST', event, reason) if (this.outbound) { endpoint.DESTROY() } this.debugger.info('emit end') this.emit('end') }) association.on('COMMUNICATION ERROR', () => { this.emit('error') }) association.on('RESTART', () => { this.emit('restart') }) association.on('SHUTDOWN COMPLETE', () => { this.debugger.debug('socket ended') if (this.outbound) { endpoint.DESTROY() } this.emit('end') }) } SCTP_ASSOCINFO(options) { const params = ['valid_cookie_life'] const endpoint = this.endpoint if (endpoint && typeof options === 'object') { params.forEach(key => { if (key in options) { endpoint[key] = options[key] } }) } } /** * Destroy() internal implementation * @param {Error} err * @param {function} callback * @returns {Socket} * @private */ _destroy(err, callback) { this.debugger.info('destroy()') // SetTimeout(() => { // todo this.association.ABORT() if (this.outbound) { this.endpoint.DESTROY() } // }, 100) callback(err) return this } } class Server extends EventEmitter { constructor(options, connectionListener) { super() if (typeof options === 'function') { connectionListener = options options = {} } else { options = options || {} } this.debugger = {} this.debugger.info = debug('sctp:server:##') this.debugger.info('server start %o', options) if (typeof connectionListener === 'function') { this.on('connection', connectionListener) } this.listening = false this.ppid = options.ppid this.udpTransport = options.transport } address() { return { port: this.localPort, address: this.localAddress, family: 'IPv4' } } close(callback) { if (!this.listening) { return } this.listening = false // Todo close connections? this.emit('close') if (typeof callback === 'function') { callback() } } listen(port, host, backlog, callback) { /* The server.listen() method can be called again if and only if there was an error during the first server.listen() call or server.close() has been called. Otherwise, an ERR_SERVER_ALREADY_LISTEN error will be thrown. */ if (typeof port === 'object') { const options = port callback = host this._listen(options, callback) } else { const options = {port, host, backlog} this._listen(options, callback) } } _listen(options, callback) { options = options || {} this.debugger.info('server try listen %o', options) if (typeof callback === 'function') { this.once('listening', callback) } const initOptions = { localPort: options.port, localAddress: toarray(options.host), MIS: options.MIS || this.maxConnections, OS: options.OS, udpTransport: this.udpTransport, } Endpoint.INITIALIZE(initOptions, (error, endpoint) => { if (error) { this.emit('error', error) } else { this.localPort = endpoint.localPort this.endpoint = endpoint const label = `[${endpoint.localPort}]` this.debugger.warn = debug(`sctp:server:### ${label}`) this.debugger.info = debug(`sctp:server:## ${label}`) this.debugger.debug = debug(`sctp:server:# ${label}`) this.debugger.trace = debug(`sctp:server: ${label}`) this.debugger.info('bound') endpoint.on('association', association => { // Todo other params const socket = new Socket({ppid: this.ppid}) socket.establish(endpoint, association) this.emit('connection', socket) this.debugger.debug('connect <- %s:%s', association.remoteAddress, association.remotePort) }) this.listening = true this.emit('listening') } }) } } function toarray(address) { if (!address) { return } let addresses = Array.isArray(address) ? address : [address] addresses = addresses.filter(address => ip.isV4Format(address)) return addresses } function isPort(port) { return Number.isInteger(port) && port > 0 && port < 0xFFFF; } module.exports = { Socket, Server }