UNPKG

strophe.js

Version:

Strophe.js is an XMPP library for JavaScript

459 lines (458 loc) 17.7 kB
import { connect as netConnect } from 'node:net'; import { createHash } from 'node:crypto'; import Builder from '../builder'; import { stx } from '../stanza'; import log from '../log'; import { ErrorCondition, NS, Status } from '../constants'; import { xmlGenerator, xmlescape } from '../utils'; import ComponentParser from './component-parser'; const DEFAULT_COMPONENT_PORT = 5347; /** * Extract the defined condition and optional descriptive text from a * `<stream:error/>` element (RFC 6120 §4.9). `condition` is the name of the * first child element other than `<text/>`; `text` is the content of a * `<text/>` child if present. Both default to an empty string so each caller * can apply its own fallback condition. * @param streamError - The `<stream:error/>` element. */ function parseStreamError(streamError) { let condition = ''; let text = ''; for (let i = 0; i < streamError.childNodes.length; i++) { const child = streamError.childNodes[i]; if (child.nodeType !== child.ELEMENT_NODE) { continue; } if (child.nodeName === 'text') { text = child.textContent || ''; } else if (!condition) { condition = child.nodeName; } } return { condition, text }; } /** * Helper class that handles XEP-0114 external component connections. * * Like {@link Websocket} it is used internally by {@link Connection} to * encapsulate a session and is not meant to be used from user code. Select it * with the `protocol: 'component'` connection option and a `tcp://host:port` * service URL. */ class Component { /** * Create and initialize a Component object. * @param connection - The Connection that will use this component transport. */ constructor(connection) { this._conn = connection; // Complete stanzas are handed to _dataRecv wrapped in a <wrapper/>, the // same convention the WebSocket transport uses, so downstream handling // is identical. this.strip = 'wrapper'; this.socket = null; this._streamId = null; this._authenticated = false; this._disconnected = false; this._parser = new ComponentParser({ onStreamStart: (attrs) => this._onStreamStart(attrs), onStanza: (stanza) => this._onStanza(stanza), onStreamEnd: () => this._onStreamEnd(), onError: (err) => this._onParseError(err), }); } /** * Parse the component service URL into a host and port. * * Accepts `tcp://host:port` (the recommended plaintext form) and tolerates a * bare `host:port`. The port defaults to 5347, the conventional component * listener port. */ _serviceUrl() { const service = this._conn.service; const url = new URL(service.includes('://') ? service : `tcp://${service}`); return { host: url.hostname || 'localhost', port: url.port ? parseInt(url.port, 10) : DEFAULT_COMPONENT_PORT, }; } /** * Reset the transport. Called by {@link Connection#reset}. */ _reset() { this._parser.reset(); this._streamId = null; this._authenticated = false; this._disconnected = false; } /** * _Private_ function called by Connection.connect * * Opens a TCP socket to the component listener and wires up its callbacks. */ _connect() { this._closeSocket(); this._reset(); const { host, port } = this._serviceUrl(); log.debug(`Component connecting to ${host}:${port}`); const socket = netConnect({ host, port }); this.socket = socket; socket.on('connect', () => this._onOpen()); socket.on('data', (chunk) => this._onData(chunk)); socket.on('error', (e) => this._onError(e)); socket.on('close', () => this._onClose()); } /** * _Private_ function called by Connection._connect_cb * * The component protocol has no <stream:features> to inspect, so there is * nothing to check here. Kept for interface parity with the other * transports. */ _connect_cb(_bodyWrap) { } /** * _Private_ function to handle a successful TCP connection. * Sends the component stream header (XEP-0114 §2). */ _onOpen() { var _a; log.debug('Component TCP socket connected'); const header = this._streamHeader(); this._conn.xmlOutput(this._streamOpenElement()); this._conn.rawOutput(header); (_a = this.socket) === null || _a === void 0 ? void 0 : _a.write(header); } /** * The opening `<stream:stream>` header sent to the server. `to` is the * component's own domain, per XEP-0114. */ _streamHeader() { return ("<?xml version='1.0'?>" + `<stream:stream xmlns='${NS.COMPONENT}' ` + `xmlns:stream='${NS.STREAM}' ` + `to='${xmlescape(this._conn.domain)}'>`); } /** * A self-closed representation of the stream header, purely for the * `xmlOutput` debug hook (the real header sent on the wire never closes). */ _streamOpenElement() { return stx `<stream:stream xmlns="${NS.COMPONENT}" xmlns:stream="${NS.STREAM}" to="${this._conn.domain}"/>`.tree(); } /** * _Private_ function called when raw bytes arrive on the socket. * Feeds them into the incremental stream parser. * @param chunk - The bytes received from the socket. */ _onData(chunk) { this._parser.write(chunk); } /** * Handle the server's opening stream header. Captures the stream id and * replies with the SHA-1 handshake (XEP-0114 §3). * @param attrs - The attributes of the server's <stream:stream> header. */ _onStreamStart(attrs) { const streamId = attrs.id; if (!streamId) { log.error('Component stream header is missing a stream id'); this._teardown(Status.CONNFAIL, 'Missing stream id in component stream header'); return; } this._streamId = streamId; this._sendHandshake(streamId); } /** * Compute and send the XEP-0114 handshake digest, * `hex( SHA1( STREAM_ID + SHARED_SECRET ) )` in lowercase. * @param streamId - The stream id from the server's stream header. */ _sendHandshake(streamId) { var _a; const secret = typeof this._conn.pass === 'string' ? this._conn.pass : ''; const digest = createHash('sha1') .update(streamId + secret, 'utf8') .digest('hex'); const handshake = stx `<handshake>${digest}</handshake>`; const raw = handshake.toString(); this._conn.xmlOutput(handshake.tree()); this._conn.rawOutput(raw); (_a = this.socket) === null || _a === void 0 ? void 0 : _a.write(raw); } /** * Route a complete inbound stanza. Before the handshake completes this is * the handshake result; afterwards it's ordinary traffic. * @param stanza - A complete top-level stanza element. */ _onStanza(stanza) { if (this._authenticated) { this._onMessage(stanza); } else { this._handleHandshakeResult(stanza); } } /** * Inspect the server's reply to our handshake. An empty `<handshake/>` * means success and the component is CONNECTED; a `<stream:error/>` (e.g. * `<not-authorized/>`) means the shared secret was wrong. * @param stanza - The first stanza received after sending the handshake. */ _handleHandshakeResult(stanza) { if (stanza.nodeName === 'handshake') { log.debug('Component handshake succeeded'); this._authenticated = true; this._conn.connected = true; this._conn.authenticated = true; // The handshake reply arrives without us having flushed the send // queue, so the idle loop may have stopped while we were still // unconnected. Restart it so timed handlers and queued stanzas keep // flowing even for a component that only receives. this._conn._scheduleIdle(); this._conn._changeConnectStatus(Status.CONNECTED, null); } else if (stanza.nodeName === 'stream:error' || stanza.nodeName === 'error') { const condition = parseStreamError(stanza).condition || 'not-authorized'; log.error(`Component handshake failed: ${condition}`); this._teardown(Status.AUTHFAIL, condition); } else { log.error(`Unexpected stanza <${stanza.nodeName}> before component handshake completed`); this._teardown(Status.AUTHFAIL, 'unexpected-stanza'); } } /** * _Private_ function to handle incoming stanzas once authenticated. * Wraps the stanza and routes it through the normal handler machinery, * exactly as {@link Websocket#_onMessage} does. * @param stanza - A complete top-level stanza element. */ _onMessage(stanza) { if (stanza.nodeName === 'stream:error') { this._checkStreamError(stanza); return; } const raw = this._serialize(stanza); const wrapper = xmlGenerator().createElement('wrapper'); wrapper.appendChild(stanza); this._conn._dataRecv(wrapper, raw); } /** * Serialize an element to a string, reusing Strophe's own serializer so * output matches the other transports. */ _serialize(elem) { var _a; return (_a = Builder.serialize(elem)) !== null && _a !== void 0 ? _a : ''; } /** * _Private_ checks a received stream error and tears the connection down. * @param streamError - The <stream:error/> element. */ _checkStreamError(streamError) { const { condition, text } = parseStreamError(streamError); const reason = condition || 'unknown'; log.error(`Component stream error: ${reason}${text ? ' - ' + text : ''}`); this._teardown(Status.CONNFAIL, reason); } /** * Handle the closing `</stream:stream>` tag from the server. */ _onStreamEnd() { log.debug('Component received </stream:stream>'); if (!this._conn.disconnecting) { this._teardown(null); } } /** * Handle a fatal XML parse error on the inbound stream. * @param error - The parse error. */ _onParseError(error) { log.error(`Component stream parse error: ${error.message}`); this._teardown(Status.CONNFAIL, ErrorCondition.BAD_FORMAT); } /** * _Private_ function called by Connection.disconnect * Sends an optional last stanza, then the closing stream tag, then * tears down the socket. * @param pres - This stanza will be sent before disconnecting. */ _disconnect(pres) { if (this.socket && !this.socket.destroyed) { if (pres) { this._conn.send(pres); } const closeString = '</stream:stream>'; this._conn.rawOutput(closeString); try { this.socket.write(closeString); } catch (e) { log.warn(`Couldn't send </stream:stream> tag. "${e.message}"`); } } setTimeout(() => this._teardown(null), 0); } /** * Tear the connection down exactly once. * * A single parsed chunk can surface several teardown triggers back to * back: a `<stream:error/>` immediately followed by the closing * `</stream:stream>`, or a socket error and then its close event. Each * would otherwise call {@link Connection#_doDisconnect} again and fire a * second DISCONNECTED. The first call through here wins; later ones are * no-ops. * @param status - A {@link Status} to report before disconnecting, or null * to disconnect without a preceding status change (e.g. a plain stream * close). * @param condition - The accompanying error condition, if any. */ _teardown(status, condition) { if (this._disconnected) { return; } this._disconnected = true; if (status !== null) { this._conn._changeConnectStatus(status, condition !== null && condition !== void 0 ? condition : null); } this._conn._doDisconnect(condition !== null && condition !== void 0 ? condition : undefined); } /** * _Private_ function to disconnect. Just closes the socket. */ _doDisconnect() { this._disconnected = true; log.debug('Component _doDisconnect was called'); this._closeSocket(); } /** * _Private_ function to close the TCP socket and drop its listeners. */ _closeSocket() { if (this.socket) { try { this.socket.removeAllListeners('connect'); this.socket.removeAllListeners('data'); this.socket.removeAllListeners('error'); this.socket.removeAllListeners('close'); this.socket.destroy(); } catch (e) { log.debug(e.message); } } this.socket = null; } /** * _Private_ function to check if the message queue is empty. * @returns True, because stanzas are written to the socket immediately. */ _emptyQueue() { return true; } /** * _Private_ function to handle the socket closing. */ _onClose() { if (this._conn.connected && !this._conn.disconnecting) { log.error('Component socket closed unexpectedly'); this._teardown(null); } else if (!this._conn.connected && this.socket) { log.error('Component socket closed before the stream was established'); this._teardown(Status.CONNFAIL, 'The TCP connection could not be established or was disconnected.'); } else { log.debug('Component socket closed'); } } /** * Called on stream start/restart when no authentication mechanism is * offered. Not reachable on the component path (there is no SASL), but kept * for interface parity. * @param callback */ _no_auth_received(callback) { log.error('Component received no authentication mechanism'); this._conn._changeConnectStatus(Status.CONNFAIL, ErrorCondition.NO_AUTH_MECH); callback === null || callback === void 0 ? void 0 : callback.call(this._conn); this._conn._doDisconnect(); } /** * _Private_ timeout handler for a non-graceful disconnection. * Nothing to do for a plain TCP socket. */ _onDisconnectTimeout() { } /** * _Private_ helper that makes sure all pending requests are aborted. * A component has no in-flight requests, so this is a no-op. */ _abortAllRequests() { } /** * _Private_ function to handle socket errors. * @param error - The socket error. */ _onError(error) { var _a; log.error('Component socket error ' + ((_a = error === null || error === void 0 ? void 0 : error.message) !== null && _a !== void 0 ? _a : JSON.stringify(error))); this._teardown(Status.CONNFAIL, 'The TCP connection could not be established or was disconnected.'); } /** * _Private_ function called by Connection._onIdle * Serializes queued stanzas and writes them to the socket, stamping a * `from` attribute under the component's domain when one is absent (the * server validates that a component stamps `from`, per XEP-0114). */ _onIdle() { var _a; const data = this._conn._data; if (data.length > 0 && !this._conn.paused) { for (let i = 0; i < data.length; i++) { const stanza = data[i]; // Components have no stream-restart concept; ignore any 'restart' // marker (it is only produced by the SASL/bind path). if (stanza === null || stanza === 'restart') { continue; } this._stampFrom(stanza); const rawStanza = this._serialize(stanza); this._conn.xmlOutput(stanza); this._conn.rawOutput(rawStanza); (_a = this.socket) === null || _a === void 0 ? void 0 : _a.write(rawStanza); } this._conn._data = []; } } /** * Ensure an outgoing stanza carries a `from` attribute under the * component's domain, which XEP-0114 requires the server to validate. * An explicit `from` (e.g. a sub-JID of the component) is left untouched. * @param stanza - The outgoing stanza element. */ _stampFrom(stanza) { const name = stanza.nodeName; if ((name === 'message' || name === 'presence' || name === 'iq') && !stanza.getAttribute('from')) { stanza.setAttribute('from', (this._conn.jid || this._conn.domain)); } } /** * _Private_ part of the Connection.send function for the component transport. * Just flushes the messages that are in the queue. */ _send() { this._conn.flush(); } /** * Send an xmpp:restart stanza. * * Components never restart their stream, so this only flushes any queued * data. Included for interface parity with the other transports. */ _sendRestart() { clearTimeout(this._conn._idleTimeout); this._conn._onIdle(); } } export default Component;