sip.js
Version:
A SIP library for JavaScript
312 lines (311 loc) • 14.7 kB
JavaScript
import { C } from "../messages/methods/constants.js";
import { IncomingResponseMessage } from "../messages/incoming-response-message.js";
import { NonInviteClientTransaction } from "../transactions/non-invite-client-transaction.js";
import { TransactionState } from "../transactions/transaction-state.js";
/**
* User Agent Client (UAC).
* @remarks
* A user agent client is a logical entity
* that creates a new request, and then uses the client
* transaction state machinery to send it. The role of UAC lasts
* only for the duration of that transaction. In other words, if
* a piece of software initiates a request, it acts as a UAC for
* the duration of that transaction. If it receives a request
* later, it assumes the role of a user agent server for the
* processing of that transaction.
* https://tools.ietf.org/html/rfc3261#section-6
* @public
*/
export class UserAgentClient {
constructor(transactionConstructor, core, message, delegate) {
this.transactionConstructor = transactionConstructor;
this.core = core;
this.message = message;
this.delegate = delegate;
this.challenged = false;
this.stale = false;
this.logger = this.loggerFactory.getLogger("sip.user-agent-client");
this.init();
}
dispose() {
this.transaction.dispose();
}
get loggerFactory() {
return this.core.loggerFactory;
}
/** The transaction associated with this request. */
get transaction() {
if (!this._transaction) {
throw new Error("Transaction undefined.");
}
return this._transaction;
}
/**
* Since requests other than INVITE are responded to immediately, sending a
* CANCEL for a non-INVITE request would always create a race condition.
* A CANCEL request SHOULD NOT be sent to cancel a request other than INVITE.
* https://tools.ietf.org/html/rfc3261#section-9.1
* @param options - Cancel options bucket.
*/
cancel(reason, options = {}) {
if (!this.transaction) {
throw new Error("Transaction undefined.");
}
if (!this.message.to) {
throw new Error("To undefined.");
}
if (!this.message.from) {
throw new Error("From undefined.");
}
// The following procedures are used to construct a CANCEL request. The
// Request-URI, Call-ID, To, the numeric part of CSeq, and From header
// fields in the CANCEL request MUST be identical to those in the
// request being cancelled, including tags. A CANCEL constructed by a
// client MUST have only a single Via header field value matching the
// top Via value in the request being cancelled. Using the same values
// for these header fields allows the CANCEL to be matched with the
// request it cancels (Section 9.2 indicates how such matching occurs).
// However, the method part of the CSeq header field MUST have a value
// of CANCEL. This allows it to be identified and processed as a
// transaction in its own right (See Section 17).
// https://tools.ietf.org/html/rfc3261#section-9.1
const message = this.core.makeOutgoingRequestMessage(C.CANCEL, this.message.ruri, this.message.from.uri, this.message.to.uri, {
toTag: this.message.toTag,
fromTag: this.message.fromTag,
callId: this.message.callId,
cseq: this.message.cseq
}, options.extraHeaders);
// TODO: Revisit this.
// The CANCEL needs to use the same branch parameter so that
// it matches the INVITE transaction, but this is a hacky way to do this.
// Or at the very least not well documented. If the the branch parameter
// is set on the outgoing request, the transaction will use it.
// Otherwise the transaction will make a new one.
message.branch = this.message.branch;
if (this.message.headers.Route) {
message.headers.Route = this.message.headers.Route;
}
if (reason) {
message.setHeader("Reason", reason);
}
// If no provisional response has been received, the CANCEL request MUST
// NOT be sent; rather, the client MUST wait for the arrival of a
// provisional response before sending the request. If the original
// request has generated a final response, the CANCEL SHOULD NOT be
// sent, as it is an effective no-op, since CANCEL has no effect on
// requests that have already generated a final response.
// https://tools.ietf.org/html/rfc3261#section-9.1
if (this.transaction.state === TransactionState.Proceeding) {
new UserAgentClient(NonInviteClientTransaction, this.core, message);
}
else {
this.transaction.addStateChangeListener(() => {
if (this.transaction && this.transaction.state === TransactionState.Proceeding) {
new UserAgentClient(NonInviteClientTransaction, this.core, message);
}
}, { once: true });
}
return message;
}
/**
* If a 401 (Unauthorized) or 407 (Proxy Authentication Required)
* response is received, the UAC SHOULD follow the authorization
* procedures of Section 22.2 and Section 22.3 to retry the request with
* credentials.
* https://tools.ietf.org/html/rfc3261#section-8.1.3.5
* 22 Usage of HTTP Authentication
* https://tools.ietf.org/html/rfc3261#section-22
* 22.1 Framework
* https://tools.ietf.org/html/rfc3261#section-22.1
* 22.2 User-to-User Authentication
* https://tools.ietf.org/html/rfc3261#section-22.2
* 22.3 Proxy-to-User Authentication
* https://tools.ietf.org/html/rfc3261#section-22.3
*
* FIXME: This "guard for and retry the request with credentials"
* implementation is not complete and at best minimally passable.
* @param response - The incoming response to guard.
* @param dialog - If defined, the dialog within which the response was received.
* @returns True if the program execution is to continue in the branch in question.
* Otherwise the request is retried with credentials and current request processing must stop.
*/
authenticationGuard(message, dialog) {
const statusCode = message.statusCode;
if (!statusCode) {
throw new Error("Response status code undefined.");
}
// If a 401 (Unauthorized) or 407 (Proxy Authentication Required)
// response is received, the UAC SHOULD follow the authorization
// procedures of Section 22.2 and Section 22.3 to retry the request with
// credentials.
// https://tools.ietf.org/html/rfc3261#section-8.1.3.5
if (statusCode !== 401 && statusCode !== 407) {
return true;
}
// Get and parse the appropriate WWW-Authenticate or Proxy-Authenticate header.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let challenge;
let authorizationHeaderName;
if (statusCode === 401) {
challenge = message.parseHeader("www-authenticate");
authorizationHeaderName = "authorization";
}
else {
challenge = message.parseHeader("proxy-authenticate");
authorizationHeaderName = "proxy-authorization";
}
// Verify it seems a valid challenge.
if (!challenge) {
this.logger.warn(statusCode + " with wrong or missing challenge, cannot authenticate");
return true;
}
// Avoid infinite authentications.
if (this.challenged && (this.stale || challenge.stale !== true)) {
this.logger.warn(statusCode + " apparently in authentication loop, cannot authenticate");
return true;
}
// Get credentials.
if (!this.credentials) {
this.credentials = this.core.configuration.authenticationFactory();
if (!this.credentials) {
this.logger.warn("Unable to obtain credentials, cannot authenticate");
return true;
}
}
// Verify that the challenge is really valid.
if (!this.credentials.authenticate(this.message, challenge)) {
return true;
}
this.challenged = true;
if (challenge.stale) {
this.stale = true;
}
// If response to out of dialog request, assume incrementing the CSeq will suffice.
let cseq = (this.message.cseq += 1);
// If response to in dialog request, get a valid next CSeq number.
if (dialog && dialog.localSequenceNumber) {
dialog.incrementLocalSequenceNumber();
cseq = this.message.cseq = dialog.localSequenceNumber;
}
this.message.setHeader("cseq", cseq + " " + this.message.method);
this.message.setHeader(authorizationHeaderName, this.credentials.toString());
// Calling init (again) will swap out our existing client transaction with a new one.
// FIXME: HACK: An assumption is being made here that there is nothing that needs to
// be cleaned up beyond the client transaction which is being replaced. For example,
// it is assumed that no early dialogs have been created.
this.init();
return false;
}
/**
* 8.1.3.1 Transaction Layer Errors
* In some cases, the response returned by the transaction layer will
* not be a SIP message, but rather a transaction layer error. When a
* timeout error is received from the transaction layer, it MUST be
* treated as if a 408 (Request Timeout) status code has been received.
* If a fatal transport error is reported by the transport layer
* (generally, due to fatal ICMP errors in UDP or connection failures in
* TCP), the condition MUST be treated as a 503 (Service Unavailable)
* status code.
* https://tools.ietf.org/html/rfc3261#section-8.1.3.1
*/
onRequestTimeout() {
this.logger.warn("User agent client request timed out. Generating internal 408 Request Timeout.");
const message = new IncomingResponseMessage();
message.statusCode = 408;
message.reasonPhrase = "Request Timeout";
this.receiveResponse(message);
return;
}
/**
* 8.1.3.1 Transaction Layer Errors
* In some cases, the response returned by the transaction layer will
* not be a SIP message, but rather a transaction layer error. When a
* timeout error is received from the transaction layer, it MUST be
* treated as if a 408 (Request Timeout) status code has been received.
* If a fatal transport error is reported by the transport layer
* (generally, due to fatal ICMP errors in UDP or connection failures in
* TCP), the condition MUST be treated as a 503 (Service Unavailable)
* status code.
* https://tools.ietf.org/html/rfc3261#section-8.1.3.1
* @param error - Transport error
*/
onTransportError(error) {
this.logger.error(error.message);
this.logger.error("User agent client request transport error. Generating internal 503 Service Unavailable.");
const message = new IncomingResponseMessage();
message.statusCode = 503;
message.reasonPhrase = "Service Unavailable";
this.receiveResponse(message);
}
/**
* Receive a response from the transaction layer.
* @param message - Incoming response message.
*/
receiveResponse(message) {
if (!this.authenticationGuard(message)) {
return;
}
const statusCode = message.statusCode ? message.statusCode.toString() : "";
if (!statusCode) {
throw new Error("Response status code undefined.");
}
switch (true) {
case /^100$/.test(statusCode):
if (this.delegate && this.delegate.onTrying) {
this.delegate.onTrying({ message });
}
break;
case /^1[0-9]{2}$/.test(statusCode):
if (this.delegate && this.delegate.onProgress) {
this.delegate.onProgress({ message });
}
break;
case /^2[0-9]{2}$/.test(statusCode):
if (this.delegate && this.delegate.onAccept) {
this.delegate.onAccept({ message });
}
break;
case /^3[0-9]{2}$/.test(statusCode):
if (this.delegate && this.delegate.onRedirect) {
this.delegate.onRedirect({ message });
}
break;
case /^[4-6][0-9]{2}$/.test(statusCode):
if (this.delegate && this.delegate.onReject) {
this.delegate.onReject({ message });
}
break;
default:
throw new Error(`Invalid status code ${statusCode}`);
}
}
init() {
// We are the transaction user.
const user = {
loggerFactory: this.loggerFactory,
onRequestTimeout: () => this.onRequestTimeout(),
onStateChange: (newState) => {
if (newState === TransactionState.Terminated) {
// Remove the terminated transaction from the core.
// eslint-disable-next-line @typescript-eslint/no-use-before-define
this.core.userAgentClients.delete(userAgentClientId);
// FIXME: HACK: Our transaction may have been swapped out with a new one
// post authentication (see above), so make sure to only to dispose of
// ourselves if this terminating transaction is our current transaction.
// eslint-disable-next-line @typescript-eslint/no-use-before-define
if (transaction === this._transaction) {
this.dispose();
}
}
},
onTransportError: (error) => this.onTransportError(error),
receiveResponse: (message) => this.receiveResponse(message)
};
// Create a new transaction with us as the user.
const transaction = new this.transactionConstructor(this.message, this.core.transport, user);
this._transaction = transaction;
// Add the new transaction to the core.
const userAgentClientId = transaction.id + transaction.request.method;
this.core.userAgentClients.set(userAgentClientId, this);
}
}