UNPKG

jssip

Version:

The Javascript SIP library

893 lines (892 loc) 29.4 kB
"use strict"; /* eslint-disable no-invalid-this */ const EventEmitter = require('events').EventEmitter; const Logger = require('./Logger'); const JsSIP_C = require('./Constants'); const Registrator = require('./Registrator'); const RTCSession = require('./RTCSession'); const Subscriber = require('./Subscriber'); const Notifier = require('./Notifier'); const Message = require('./Message'); const Options = require('./Options'); const Transactions = require('./Transactions'); const Transport = require('./Transport'); const Utils = require('./Utils'); const Exceptions = require('./Exceptions'); const URI = require('./URI'); const Parser = require('./Parser'); const SIPMessage = require('./SIPMessage'); const sanityCheck = require('./sanityCheck'); const config = require('./Config'); const logger = new Logger('UA'); const C = { // UA status codes. STATUS_INIT: 0, STATUS_READY: 1, STATUS_USER_CLOSED: 2, STATUS_NOT_READY: 3, // UA error codes. CONFIGURATION_ERROR: 1, NETWORK_ERROR: 2, }; /** * The User-Agent class. * @class JsSIP.UA * @param {Object} configuration Configuration parameters. * @throws {JsSIP.Exceptions.ConfigurationError} If a configuration parameter is invalid. * @throws {TypeError} If no configuration is given. */ module.exports = class UA extends EventEmitter { // Expose C object. static get C() { return C; } constructor(configuration) { // Check configuration argument. if (!configuration) { throw new TypeError('Not enough arguments'); } // Hide sensitive information. const sensitiveKeys = ['password', 'ha1', 'authorization_jwt']; logger.debug('new() [configuration:%o]', Object.entries(configuration).filter(([key]) => !sensitiveKeys.includes(key))); super(); this._cache = { credentials: {}, }; this._configuration = Object.assign({}, config.settings); this._dynConfiguration = {}; this._dialogs = {}; // User actions outside any session/dialog (MESSAGE/OPTIONS). this._applicants = {}; this._sessions = {}; this._transport = null; this._contact = null; this._status = C.STATUS_INIT; this._error = null; this._transactions = { nist: {}, nict: {}, ist: {}, ict: {}, }; // Custom UA empty object for high level use. this._data = {}; this._closeTimer = null; // Load configuration. try { this._loadConfig(configuration); } catch (error) { this._status = C.STATUS_NOT_READY; this._error = C.CONFIGURATION_ERROR; throw error; } // Initialize registrator. this._registrator = new Registrator(this); } get C() { return C; } get status() { return this._status; } get contact() { return this._contact; } get configuration() { return this._configuration; } get transport() { return this._transport; } // ================= // High Level API // ================= /** * Connect to the server if status = STATUS_INIT. * Resume UA after being closed. */ start() { logger.debug('start()'); if (this._status === C.STATUS_INIT) { this._transport.connect(); } else if (this._status === C.STATUS_USER_CLOSED) { logger.debug('restarting UA'); // Disconnect. if (this._closeTimer !== null) { clearTimeout(this._closeTimer); this._closeTimer = null; this._transport.disconnect(); } // Reconnect. this._status = C.STATUS_INIT; this._transport.connect(); } else if (this._status === C.STATUS_READY) { logger.debug('UA is in READY status, not restarted'); } else { logger.debug('ERROR: connection is down, Auto-Recovery system is trying to reconnect'); } // Set dynamic configuration. this._dynConfiguration.register = this._configuration.register; } /** * Register. */ register() { logger.debug('register()'); this._dynConfiguration.register = true; this._registrator.register(); } /** * Unregister. */ unregister(options) { logger.debug('unregister()'); this._dynConfiguration.register = false; this._registrator.unregister(options); } /** * Get the Registrator instance. */ registrator() { return this._registrator; } /** * Registration state. */ isRegistered() { return this._registrator.registered; } /** * Connection state. */ isConnected() { return this._transport.isConnected(); } /** * Make an outgoing call. * * -param {String} target * -param {Object} [options] * * -throws {TypeError} * */ call(target, options) { logger.debug('call()'); const session = new RTCSession(this); session.connect(target, options); return session; } /** * Send a message. * * -param {String} target * -param {String} body * -param {Object} [options] * * -throws {TypeError} * */ sendMessage(target, body, options) { logger.debug('sendMessage()'); const message = new Message(this); message.send(target, body, options); return message; } /** * Create subscriber instance */ subscribe(target, eventName, accept, options) { logger.debug('subscribe()'); return new Subscriber(this, target, eventName, accept, options); } /** * Create notifier instance */ notify(subscribe, contentType, options) { logger.debug('notify()'); return new Notifier(this, subscribe, contentType, options); } /** * Send a SIP OPTIONS. * * -param {String} target * -param {String} [body] * -param {Object} [options] * * -throws {TypeError} * */ sendOptions(target, body, options) { logger.debug('sendOptions()'); const message = new Options(this); message.send(target, body, options); return message; } /** * Terminate ongoing sessions. */ terminateSessions(options) { logger.debug('terminateSessions()'); for (const idx in this._sessions) { if (!this._sessions[idx].isEnded()) { this._sessions[idx].terminate(options); } } } /** * Gracefully close. * */ stop() { logger.debug('stop()'); // Remove dynamic settings. this._dynConfiguration = {}; if (this._status === C.STATUS_USER_CLOSED) { logger.debug('UA already closed'); return; } // Close registrator. this._registrator.close(); // If there are session wait a bit so CANCEL/BYE can be sent and their responses received. const num_sessions = Object.keys(this._sessions).length; // Run _terminate_ on every Session. for (const session in this._sessions) { if (Object.prototype.hasOwnProperty.call(this._sessions, session)) { logger.debug(`closing session ${session}`); try { this._sessions[session].terminate(); } catch (error) { } } } // Run _close_ on every applicant. for (const applicant in this._applicants) { if (Object.prototype.hasOwnProperty.call(this._applicants, applicant)) { try { this._applicants[applicant].close(); } catch (error) { } } } this._status = C.STATUS_USER_CLOSED; const num_transactions = Object.keys(this._transactions.nict).length + Object.keys(this._transactions.nist).length + Object.keys(this._transactions.ict).length + Object.keys(this._transactions.ist).length; if (num_transactions === 0 && num_sessions === 0) { this._transport.disconnect(); } else { this._closeTimer = setTimeout(() => { this._closeTimer = null; this._transport.disconnect(); }, 2000); } } /** * Normalice a string into a valid SIP request URI * -param {String} target * -returns {JsSIP.URI|undefined} */ normalizeTarget(target) { return Utils.normalizeTarget(target, this._configuration.hostport_params); } /** * Allow retrieving configuration and autogenerated fields in runtime. */ get(parameter) { switch (parameter) { case 'authorization_user': { return this._configuration.authorization_user; } case 'realm': { return this._configuration.realm; } case 'ha1': { return this._configuration.ha1; } case 'authorization_jwt': { return this._configuration.authorization_jwt; } default: { logger.warn('get() | cannot get "%s" parameter in runtime', parameter); return undefined; } } } /** * Allow configuration changes in runtime. * Returns true if the parameter could be set. */ set(parameter, value) { switch (parameter) { case 'authorization_user': { this._configuration.authorization_user = String(value); break; } case 'password': { this._configuration.password = String(value); break; } case 'realm': { this._configuration.realm = String(value); break; } case 'ha1': { this._configuration.ha1 = String(value); // Delete the plain SIP password. this._configuration.password = null; break; } case 'authorization_jwt': { this._configuration.authorization_jwt = String(value); break; } case 'display_name': { this._configuration.display_name = value; break; } case 'extra_headers': { this._configuration.extra_headers = value; break; } default: { logger.warn('set() | cannot set "%s" parameter in runtime', parameter); return false; } } return true; } // ========================== // Event Handlers. // ========================== /** * new Transaction */ newTransaction(transaction) { this._transactions[transaction.type][transaction.id] = transaction; this.emit('newTransaction', { transaction, }); } /** * Transaction destroyed. */ destroyTransaction(transaction) { delete this._transactions[transaction.type][transaction.id]; this.emit('transactionDestroyed', { transaction, }); } /** * new Dialog */ newDialog(dialog) { this._dialogs[dialog.id] = dialog; } /** * Dialog destroyed. */ destroyDialog(dialog) { delete this._dialogs[dialog.id]; } /** * new Message */ newMessage(message, data) { this._applicants[message] = message; this.emit('newMessage', data); } /** * new Options */ newOptions(message, data) { this._applicants[message] = message; this.emit('newOptions', data); } /** * Message destroyed. */ destroyMessage(message) { delete this._applicants[message]; } /** * new RTCSession */ newRTCSession(session, data) { this._sessions[session.id] = session; this.emit('newRTCSession', data); } /** * RTCSession destroyed. */ destroyRTCSession(session) { delete this._sessions[session.id]; } /** * Registered */ registered(data) { this.emit('registered', data); } /** * Unregistered */ unregistered(data) { this.emit('unregistered', data); } /** * Registration Failed */ registrationFailed(data) { this.emit('registrationFailed', data); } // ========================= // ReceiveRequest. // ========================= /** * Request reception */ receiveRequest(request) { const method = request.method; // Check that request URI points to us. if (request.ruri.user !== this._configuration.uri.user && request.ruri.user !== this._contact.uri.user) { logger.debug('Request-URI does not point to us'); if (request.method !== JsSIP_C.ACK) { request.reply_sl(404); } return; } // Check request URI scheme. if (request.ruri.scheme === JsSIP_C.SIPS) { request.reply_sl(416); return; } // Check transaction. if (Transactions.checkTransaction(this, request)) { return; } // Create the server transaction. if (method === JsSIP_C.INVITE) { /* eslint-disable no-new */ new Transactions.InviteServerTransaction(this, this._transport, request); /* eslint-enable no-new */ } else if (method !== JsSIP_C.ACK && method !== JsSIP_C.CANCEL) { /* eslint-disable no-new */ new Transactions.NonInviteServerTransaction(this, this._transport, request); /* eslint-enable no-new */ } /* RFC3261 12.2.2 * Requests that do not change in any way the state of a dialog may be * received within a dialog (for example, an OPTIONS request). * They are processed as if they had been received outside the dialog. */ if (method === JsSIP_C.OPTIONS) { if (this.listeners('newOptions').length === 0) { request.reply(200); return; } const message = new Options(this); message.init_incoming(request); } else if (method === JsSIP_C.MESSAGE) { if (this.listeners('newMessage').length === 0) { request.reply(405); return; } const message = new Message(this); message.init_incoming(request); } else if (method === JsSIP_C.SUBSCRIBE) { if (this.listeners('newSubscribe').length === 0) { request.reply(405); return; } } else if (method === JsSIP_C.INVITE) { // Initial INVITE. if (!request.to_tag && this.listeners('newRTCSession').length === 0) { request.reply(405); return; } } let dialog; let session; // Initial Request. if (!request.to_tag) { switch (method) { case JsSIP_C.INVITE: { // eslint-disable-next-line no-undef if (window.RTCPeerConnection) { // TODO if (request.hasHeader('replaces')) { const replaces = request.replaces; dialog = this._findDialog(replaces.call_id, replaces.from_tag, replaces.to_tag); if (dialog) { session = dialog.owner; if (!session.isEnded()) { session.receiveRequest(request); } else { request.reply(603); } } else { request.reply(481); } } else { session = new RTCSession(this); session.init_incoming(request); } } else { logger.warn('INVITE received but WebRTC is not supported'); request.reply(488); } break; } case JsSIP_C.BYE: { // Out of dialog BYE received. request.reply(481); break; } case JsSIP_C.CANCEL: { session = this._findSession(request); if (session) { session.receiveRequest(request); } else { logger.debug('received CANCEL request for a non existent session'); } break; } case JsSIP_C.ACK: { /* Absorb it. * ACK request without a corresponding Invite Transaction * and without To tag. */ break; } case JsSIP_C.NOTIFY: { // Receive new sip event. this.emit('sipEvent', { event: request.event, request, }); request.reply(200); break; } case JsSIP_C.SUBSCRIBE: { Notifier.init_incoming(request, () => { this.emit('newSubscribe', { event: request.event, request, }); }); break; } default: { request.reply(405); break; } } } // In-dialog request. else { dialog = this._findDialog(request.call_id, request.from_tag, request.to_tag); if (dialog) { dialog.receiveRequest(request); } else if (method === JsSIP_C.NOTIFY) { session = this._findSession(request); if (session) { session.receiveRequest(request); } else { logger.debug('received NOTIFY request for a non existent subscription'); request.reply(481, 'Subscription does not exist'); } } else if (method !== JsSIP_C.ACK) { /* RFC3261 12.2.2 * Request with to tag, but no matching dialog found. * Exception: ACK for an Invite request for which a dialog has not * been created. */ request.reply(481); } } } // ================= // Utils. // ================= /** * Get the session to which the request belongs to, if any. */ _findSession({ call_id, from_tag, to_tag }) { const sessionIDa = call_id + from_tag; const sessionA = this._sessions[sessionIDa]; const sessionIDb = call_id + to_tag; const sessionB = this._sessions[sessionIDb]; if (sessionA) { return sessionA; } else if (sessionB) { return sessionB; } else { return null; } } /** * Get the dialog to which the request belongs to, if any. */ _findDialog(call_id, from_tag, to_tag) { let id = call_id + from_tag + to_tag; let dialog = this._dialogs[id]; if (dialog) { return dialog; } else { id = call_id + to_tag + from_tag; dialog = this._dialogs[id]; if (dialog) { return dialog; } else { return null; } } } _loadConfig(configuration) { // Check and load the given configuration. // This can throw. config.load(this._configuration, configuration); // Post Configuration Process. // Allow passing 0 number as display_name. if (this._configuration.display_name === 0) { this._configuration.display_name = '0'; } // Instance-id for GRUU. if (!this._configuration.instance_id) { this._configuration.instance_id = Utils.newUUID(); } // Jssip_id instance parameter. Static random tag of length 5. this._configuration.jssip_id = Utils.createRandomToken(5); // String containing this._configuration.uri without scheme and user. const hostport_params = this._configuration.uri.clone(); hostport_params.user = null; this._configuration.hostport_params = hostport_params .toString() .replace(/^sip:/i, ''); // Transport. try { this._transport = new Transport(this._configuration.sockets, { // Recovery options. max_interval: this._configuration.connection_recovery_max_interval, min_interval: this._configuration.connection_recovery_min_interval, }); // Transport event callbacks. this._transport.onconnecting = onTransportConnecting.bind(this); this._transport.onconnect = onTransportConnect.bind(this); this._transport.ondisconnect = onTransportDisconnect.bind(this); this._transport.ondata = onTransportData.bind(this); } catch (error) { logger.warn(error); throw new Exceptions.ConfigurationError('sockets', this._configuration.sockets); } // Remove sockets instance from configuration object. delete this._configuration.sockets; // Check whether authorization_user is explicitly defined. // Take 'this._configuration.uri.user' value if not. if (!this._configuration.authorization_user) { this._configuration.authorization_user = this._configuration.uri.user; } // If no 'registrar_server' is set use the 'uri' value without user portion and // without URI params/headers. if (!this._configuration.registrar_server) { const registrar_server = this._configuration.uri.clone(); registrar_server.user = null; registrar_server.clearParams(); registrar_server.clearHeaders(); this._configuration.registrar_server = registrar_server; } // User no_answer_timeout. this._configuration.no_answer_timeout *= 1000; // Via Host. if (this._configuration.contact_uri) { this._configuration.via_host = this._configuration.contact_uri.host; } // Contact URI. else { this._configuration.contact_uri = new URI('sip', Utils.createRandomToken(8), this._configuration.via_host, null, { transport: 'ws' }); } this._contact = { pub_gruu: null, temp_gruu: null, uri: this._configuration.contact_uri, toString(options = {}) { const anonymous = options.anonymous || null; const outbound = options.outbound || null; let contact = '<'; if (anonymous) { contact += this.temp_gruu || 'sip:anonymous@anonymous.invalid;transport=ws'; } else { contact += this.pub_gruu || this.uri.toString(); } if (outbound && (anonymous ? !this.temp_gruu : !this.pub_gruu)) { contact += ';ob'; } contact += '>'; return contact; }, }; // Seal the configuration. const writable_parameters = [ 'authorization_user', 'password', 'realm', 'ha1', 'authorization_jwt', 'display_name', 'register', 'extra_headers', ]; for (const parameter in this._configuration) { if (Object.prototype.hasOwnProperty.call(this._configuration, parameter)) { if (writable_parameters.indexOf(parameter) !== -1) { Object.defineProperty(this._configuration, parameter, { writable: true, configurable: false, }); } else { Object.defineProperty(this._configuration, parameter, { writable: false, configurable: false, }); } } } logger.debug('configuration parameters after validation:'); for (const parameter in this._configuration) { // Only show the user user configurable parameters. if (Object.prototype.hasOwnProperty.call(config.settings, parameter)) { switch (parameter) { case 'uri': case 'registrar_server': { logger.debug(`- ${parameter}: ${this._configuration[parameter]}`); break; } case 'password': case 'ha1': case 'authorization_jwt': { logger.debug(`- ${parameter}: NOT SHOWN`); break; } default: { logger.debug(`- ${parameter}: ${JSON.stringify(this._configuration[parameter])}`); } } } } return; } }; /** * Transport event handlers */ // Transport connecting event. function onTransportConnecting(data) { this.emit('connecting', data); } // Transport connected event. function onTransportConnect(data) { if (this._status === C.STATUS_USER_CLOSED) { return; } this._status = C.STATUS_READY; this._error = null; this.emit('connected', data); if (this._dynConfiguration.register) { this._registrator.register(); } } // Transport disconnected event. function onTransportDisconnect(data) { // Run _onTransportError_ callback on every client transaction using _transport_. const client_transactions = ['nict', 'ict', 'nist', 'ist']; for (const type of client_transactions) { for (const id in this._transactions[type]) { if (Object.prototype.hasOwnProperty.call(this._transactions[type], id)) { this._transactions[type][id].onTransportError(); } } } this.emit('disconnected', data); // Call registrator _onTransportClosed_. this._registrator.onTransportClosed(); if (this._status !== C.STATUS_USER_CLOSED) { this._status = C.STATUS_NOT_READY; this._error = C.NETWORK_ERROR; } } // Transport data event. function onTransportData(data) { const transport = data.transport; let message = data.message; message = Parser.parseMessage(message, this); if (!message) { return; } if (this._status === C.STATUS_USER_CLOSED && message instanceof SIPMessage.IncomingRequest) { return; } // Do some sanity check. if (!sanityCheck(message, this, transport)) { return; } if (message instanceof SIPMessage.IncomingRequest) { message.transport = transport; this.receiveRequest(message); } else if (message instanceof SIPMessage.IncomingResponse) { /* Unike stated in 18.1.2, if a response does not match * any transaction, it is discarded here and no passed to the core * in order to be discarded there. */ let transaction; switch (message.method) { case JsSIP_C.INVITE: { transaction = this._transactions.ict[message.via_branch]; if (transaction) { transaction.receiveResponse(message); } break; } case JsSIP_C.ACK: { // Just in case ;-). break; } default: { transaction = this._transactions.nict[message.via_branch]; if (transaction) { transaction.receiveResponse(message); } break; } } } }