sip.js
Version:
A SIP library for JavaScript
476 lines (475 loc) • 21.8 kB
JavaScript
import { NameAddrHeader } from "../../grammar/name-addr-header.js";
import { C } from "../messages/methods/constants.js";
import { SubscriptionState } from "../subscription/subscription.js";
import { Timers } from "../timers.js";
import { AllowedMethods } from "../user-agent-core/allowed-methods.js";
import { NotifyUserAgentServer } from "../user-agents/notify-user-agent-server.js";
import { ReSubscribeUserAgentClient } from "../user-agents/re-subscribe-user-agent-client.js";
import { Dialog } from "./dialog.js";
/**
* Subscription Dialog.
* @remarks
* SIP-Specific Event Notification
*
* Abstract
*
* This document describes an extension to the Session Initiation
* Protocol (SIP) defined by RFC 3261. The purpose of this extension is
* to provide an extensible framework by which SIP nodes can request
* notification from remote nodes indicating that certain events have
* occurred.
*
* Note that the event notification mechanisms defined herein are NOT
* intended to be a general-purpose infrastructure for all classes of
* event subscription and notification.
*
* This document represents a backwards-compatible improvement on the
* original mechanism described by RFC 3265, taking into account several
* years of implementation experience. Accordingly, this document
* obsoletes RFC 3265. This document also updates RFC 4660 slightly to
* accommodate some small changes to the mechanism that were discussed
* in that document.
*
* https://tools.ietf.org/html/rfc6665
* @public
*/
export class SubscriptionDialog extends Dialog {
constructor(subscriptionEvent, subscriptionExpires, subscriptionState, core, state, delegate) {
super(core, state);
this.delegate = delegate;
this._autoRefresh = false;
this._subscriptionEvent = subscriptionEvent;
this._subscriptionExpires = subscriptionExpires;
this._subscriptionExpiresInitial = subscriptionExpires;
this._subscriptionExpiresLastSet = Math.floor(Date.now() / 1000);
this._subscriptionRefresh = undefined;
this._subscriptionRefreshLastSet = undefined;
this._subscriptionState = subscriptionState;
this.logger = core.loggerFactory.getLogger("sip.subscribe-dialog");
this.logger.log(`SUBSCRIBE dialog ${this.id} constructed`);
}
/**
* When a UAC receives a response that establishes a dialog, it
* constructs the state of the dialog. This state MUST be maintained
* for the duration of the dialog.
* https://tools.ietf.org/html/rfc3261#section-12.1.2
* @param outgoingRequestMessage - Outgoing request message for dialog.
* @param incomingResponseMessage - Incoming response message creating dialog.
*/
static initialDialogStateForSubscription(outgoingSubscribeRequestMessage, incomingNotifyRequestMessage) {
// If the request was sent over TLS, and the Request-URI contained a
// SIPS URI, the "secure" flag is set to TRUE.
// https://tools.ietf.org/html/rfc3261#section-12.1.2
const secure = false; // FIXME: Currently no support for TLS.
// The route set MUST be set to the list of URIs in the Record-Route
// header field from the response, taken in reverse order and preserving
// all URI parameters. If no Record-Route header field is present in
// the response, the route set MUST be set to the empty set. This route
// set, even if empty, overrides any pre-existing route set for future
// requests in this dialog. The remote target MUST be set to the URI
// from the Contact header field of the response.
// https://tools.ietf.org/html/rfc3261#section-12.1.2
const routeSet = incomingNotifyRequestMessage.getHeaders("record-route");
const contact = incomingNotifyRequestMessage.parseHeader("contact");
if (!contact) {
// TODO: Review to make sure this will never happen
throw new Error("Contact undefined.");
}
if (!(contact instanceof NameAddrHeader)) {
throw new Error("Contact not instance of NameAddrHeader.");
}
const remoteTarget = contact.uri;
// The local sequence number MUST be set to the value of the sequence
// number in the CSeq header field of the request. The remote sequence
// number MUST be empty (it is established when the remote UA sends a
// request within the dialog). The call identifier component of the
// dialog ID MUST be set to the value of the Call-ID in the request.
// The local tag component of the dialog ID MUST be set to the tag in
// the From field in the request, and the remote tag component of the
// dialog ID MUST be set to the tag in the To field of the response. A
// UAC MUST be prepared to receive a response without a tag in the To
// field, in which case the tag is considered to have a value of null.
//
// This is to maintain backwards compatibility with RFC 2543, which
// did not mandate To tags.
//
// https://tools.ietf.org/html/rfc3261#section-12.1.2
const localSequenceNumber = outgoingSubscribeRequestMessage.cseq;
const remoteSequenceNumber = undefined;
const callId = outgoingSubscribeRequestMessage.callId;
const localTag = outgoingSubscribeRequestMessage.fromTag;
const remoteTag = incomingNotifyRequestMessage.fromTag;
if (!callId) {
// TODO: Review to make sure this will never happen
throw new Error("Call id undefined.");
}
if (!localTag) {
// TODO: Review to make sure this will never happen
throw new Error("From tag undefined.");
}
if (!remoteTag) {
// TODO: Review to make sure this will never happen
throw new Error("To tag undefined."); // FIXME: No backwards compatibility with RFC 2543
}
// The remote URI MUST be set to the URI in the To field, and the local
// URI MUST be set to the URI in the From field.
// https://tools.ietf.org/html/rfc3261#section-12.1.2
if (!outgoingSubscribeRequestMessage.from) {
// TODO: Review to make sure this will never happen
throw new Error("From undefined.");
}
if (!outgoingSubscribeRequestMessage.to) {
// TODO: Review to make sure this will never happen
throw new Error("To undefined.");
}
const localURI = outgoingSubscribeRequestMessage.from.uri;
const remoteURI = outgoingSubscribeRequestMessage.to.uri;
// A dialog can also be in the "early" state, which occurs when it is
// created with a provisional response, and then transition to the
// "confirmed" state when a 2xx final response arrives.
// https://tools.ietf.org/html/rfc3261#section-12
const early = false;
const dialogState = {
id: callId + localTag + remoteTag,
early,
callId,
localTag,
remoteTag,
localSequenceNumber,
remoteSequenceNumber,
localURI,
remoteURI,
remoteTarget,
routeSet,
secure
};
return dialogState;
}
dispose() {
super.dispose();
if (this.N) {
clearTimeout(this.N);
this.N = undefined;
}
this.refreshTimerClear();
this.logger.log(`SUBSCRIBE dialog ${this.id} destroyed`);
}
get autoRefresh() {
return this._autoRefresh;
}
set autoRefresh(autoRefresh) {
this._autoRefresh = true;
this.refreshTimerSet();
}
get subscriptionEvent() {
return this._subscriptionEvent;
}
/** Number of seconds until subscription expires. */
get subscriptionExpires() {
const secondsSinceLastSet = Math.floor(Date.now() / 1000) - this._subscriptionExpiresLastSet;
const secondsUntilExpires = this._subscriptionExpires - secondsSinceLastSet;
return Math.max(secondsUntilExpires, 0);
}
set subscriptionExpires(expires) {
if (expires < 0) {
throw new Error("Expires must be greater than or equal to zero.");
}
this._subscriptionExpires = expires;
this._subscriptionExpiresLastSet = Math.floor(Date.now() / 1000);
if (this.autoRefresh) {
const refresh = this.subscriptionRefresh;
if (refresh === undefined || refresh >= expires) {
this.refreshTimerSet();
}
}
}
get subscriptionExpiresInitial() {
return this._subscriptionExpiresInitial;
}
/** Number of seconds until subscription auto refresh. */
get subscriptionRefresh() {
if (this._subscriptionRefresh === undefined || this._subscriptionRefreshLastSet === undefined) {
return undefined;
}
const secondsSinceLastSet = Math.floor(Date.now() / 1000) - this._subscriptionRefreshLastSet;
const secondsUntilExpires = this._subscriptionRefresh - secondsSinceLastSet;
return Math.max(secondsUntilExpires, 0);
}
get subscriptionState() {
return this._subscriptionState;
}
/**
* Receive in dialog request message from transport.
* @param message - The incoming request message.
*/
receiveRequest(message) {
this.logger.log(`SUBSCRIBE dialog ${this.id} received ${message.method} request`);
// Request within a dialog out of sequence guard.
// https://tools.ietf.org/html/rfc3261#section-12.2.2
if (!this.sequenceGuard(message)) {
this.logger.log(`SUBSCRIBE dialog ${this.id} rejected out of order ${message.method} request.`);
return;
}
// Request within a dialog common processing.
// https://tools.ietf.org/html/rfc3261#section-12.2.2
super.receiveRequest(message);
// Switch on method and then delegate.
switch (message.method) {
case C.NOTIFY:
this.onNotify(message);
break;
default:
this.logger.log(`SUBSCRIBE dialog ${this.id} received unimplemented ${message.method} request`);
this.core.replyStateless(message, { statusCode: 501 });
break;
}
}
/**
* 4.1.2.2. Refreshing of Subscriptions
* https://tools.ietf.org/html/rfc6665#section-4.1.2.2
*/
refresh() {
const allowHeader = "Allow: " + AllowedMethods.toString();
const options = {};
options.extraHeaders = (options.extraHeaders || []).slice();
options.extraHeaders.push(allowHeader);
options.extraHeaders.push("Event: " + this.subscriptionEvent);
options.extraHeaders.push("Expires: " + this.subscriptionExpiresInitial);
options.extraHeaders.push("Contact: " + this.core.configuration.contact.toString());
return this.subscribe(undefined, options);
}
/**
* 4.1.2.2. Refreshing of Subscriptions
* https://tools.ietf.org/html/rfc6665#section-4.1.2.2
* @param delegate - Delegate to handle responses.
* @param options - Options bucket.
*/
subscribe(delegate, options = {}) {
var _a;
if (this.subscriptionState !== SubscriptionState.Pending && this.subscriptionState !== SubscriptionState.Active) {
// FIXME: This needs to be a proper exception
throw new Error(`Invalid state ${this.subscriptionState}. May only re-subscribe while in state "pending" or "active".`);
}
this.logger.log(`SUBSCRIBE dialog ${this.id} sending SUBSCRIBE request`);
const uac = new ReSubscribeUserAgentClient(this, delegate, options);
// Abort any outstanding timer (as it would otherwise become guaranteed to terminate us).
if (this.N) {
clearTimeout(this.N);
this.N = undefined;
}
if (!((_a = options.extraHeaders) === null || _a === void 0 ? void 0 : _a.includes("Expires: 0"))) {
// When refreshing a subscription, a subscriber starts Timer N, set to
// 64*T1, when it sends the SUBSCRIBE request.
// https://tools.ietf.org/html/rfc6665#section-4.1.2.2
this.N = setTimeout(() => this.timerN(), Timers.TIMER_N);
}
return uac;
}
/**
* 4.4.1. Dialog Creation and Termination
* A subscription is destroyed after a notifier sends a NOTIFY request
* with a "Subscription-State" of "terminated", or in certain error
* situations described elsewhere in this document.
* https://tools.ietf.org/html/rfc6665#section-4.4.1
*/
terminate() {
this.stateTransition(SubscriptionState.Terminated);
this.onTerminated();
}
/**
* 4.1.2.3. Unsubscribing
* https://tools.ietf.org/html/rfc6665#section-4.1.2.3
*/
unsubscribe() {
const allowHeader = "Allow: " + AllowedMethods.toString();
const options = {};
options.extraHeaders = (options.extraHeaders || []).slice();
options.extraHeaders.push(allowHeader);
options.extraHeaders.push("Event: " + this.subscriptionEvent);
options.extraHeaders.push("Expires: 0");
options.extraHeaders.push("Contact: " + this.core.configuration.contact.toString());
return this.subscribe(undefined, options);
}
/**
* Handle in dialog NOTIFY requests.
* This does not include the first NOTIFY which created the dialog.
* @param message - The incoming NOTIFY request message.
*/
onNotify(message) {
// If, for some reason, the event package designated in the "Event"
// header field of the NOTIFY request is not supported, the subscriber
// will respond with a 489 (Bad Event) response.
// https://tools.ietf.org/html/rfc6665#section-4.1.3
const event = message.parseHeader("Event").event;
if (!event || event !== this.subscriptionEvent) {
this.core.replyStateless(message, { statusCode: 489 });
return;
}
// In the state diagram, "Re-subscription times out" means that an
// attempt to refresh or update the subscription using a new SUBSCRIBE
// request does not result in a NOTIFY request before the corresponding
// Timer N expires.
// https://tools.ietf.org/html/rfc6665#section-4.1.2
if (this.N) {
clearTimeout(this.N);
this.N = undefined;
}
// NOTIFY requests MUST contain "Subscription-State" header fields that
// indicate the status of the subscription.
// https://tools.ietf.org/html/rfc6665#section-4.1.3
const subscriptionState = message.parseHeader("Subscription-State");
if (!subscriptionState || !subscriptionState.state) {
this.core.replyStateless(message, { statusCode: 489 });
return;
}
const state = subscriptionState.state;
const expires = subscriptionState.expires ? Math.max(subscriptionState.expires, 0) : undefined;
// Update our state and expiration.
switch (state) {
case "pending":
this.stateTransition(SubscriptionState.Pending, expires);
break;
case "active":
this.stateTransition(SubscriptionState.Active, expires);
break;
case "terminated":
this.stateTransition(SubscriptionState.Terminated, expires);
break;
default:
this.logger.warn("Unrecognized subscription state.");
break;
}
// Delegate remainder of NOTIFY handling.
const uas = new NotifyUserAgentServer(this, message);
if (this.delegate && this.delegate.onNotify) {
this.delegate.onNotify(uas);
}
else {
uas.accept();
}
}
onRefresh(request) {
if (this.delegate && this.delegate.onRefresh) {
this.delegate.onRefresh(request);
}
}
onTerminated() {
if (this.delegate && this.delegate.onTerminated) {
this.delegate.onTerminated();
}
}
refreshTimerClear() {
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
this.refreshTimer = undefined;
}
}
refreshTimerSet() {
this.refreshTimerClear();
if (this.autoRefresh && this.subscriptionExpires > 0) {
const refresh = this.subscriptionExpires * 900;
this._subscriptionRefresh = Math.floor(refresh / 1000);
this._subscriptionRefreshLastSet = Math.floor(Date.now() / 1000);
this.refreshTimer = setTimeout(() => {
this.refreshTimer = undefined;
this._subscriptionRefresh = undefined;
this._subscriptionRefreshLastSet = undefined;
this.onRefresh(this.refresh());
}, refresh);
}
}
stateTransition(newState, newExpires) {
// Assert valid state transitions.
const invalidStateTransition = () => {
this.logger.warn(`Invalid subscription state transition from ${this.subscriptionState} to ${newState}`);
};
switch (newState) {
case SubscriptionState.Initial:
invalidStateTransition();
return;
case SubscriptionState.NotifyWait:
invalidStateTransition();
return;
case SubscriptionState.Pending:
if (this.subscriptionState !== SubscriptionState.NotifyWait &&
this.subscriptionState !== SubscriptionState.Pending) {
invalidStateTransition();
return;
}
break;
case SubscriptionState.Active:
if (this.subscriptionState !== SubscriptionState.NotifyWait &&
this.subscriptionState !== SubscriptionState.Pending &&
this.subscriptionState !== SubscriptionState.Active) {
invalidStateTransition();
return;
}
break;
case SubscriptionState.Terminated:
if (this.subscriptionState !== SubscriptionState.NotifyWait &&
this.subscriptionState !== SubscriptionState.Pending &&
this.subscriptionState !== SubscriptionState.Active) {
invalidStateTransition();
return;
}
break;
default:
invalidStateTransition();
return;
}
// If the "Subscription-State" value is "pending", the subscription has
// been received by the notifier, but there is insufficient policy
// information to grant or deny the subscription yet. If the header
// field also contains an "expires" parameter, the subscriber SHOULD
// take it as the authoritative subscription duration and adjust
// accordingly. No further action is necessary on the part of the
// subscriber. The "retry-after" and "reason" parameters have no
// semantics for "pending".
// https://tools.ietf.org/html/rfc6665#section-4.1.3
if (newState === SubscriptionState.Pending) {
if (newExpires) {
this.subscriptionExpires = newExpires;
}
}
// If the "Subscription-State" header field value is "active", it means
// that the subscription has been accepted and (in general) has been
// authorized. If the header field also contains an "expires"
// parameter, the subscriber SHOULD take it as the authoritative
// subscription duration and adjust accordingly. The "retry-after" and
// "reason" parameters have no semantics for "active".
// https://tools.ietf.org/html/rfc6665#section-4.1.3
if (newState === SubscriptionState.Active) {
if (newExpires) {
this.subscriptionExpires = newExpires;
}
}
// If the "Subscription-State" value is "terminated", the subscriber
// MUST consider the subscription terminated. The "expires" parameter
// has no semantics for "terminated" -- notifiers SHOULD NOT include an
// "expires" parameter on a "Subscription-State" header field with a
// value of "terminated", and subscribers MUST ignore any such
// parameter, if present.
if (newState === SubscriptionState.Terminated) {
this.dispose();
}
this._subscriptionState = newState;
}
/**
* When refreshing a subscription, a subscriber starts Timer N, set to
* 64*T1, when it sends the SUBSCRIBE request. If this Timer N expires
* prior to the receipt of a NOTIFY request, the subscriber considers
* the subscription terminated. If the subscriber receives a success
* response to the SUBSCRIBE request that indicates that no NOTIFY
* request will be generated -- such as the 204 response defined for use
* with the optional extension described in [RFC5839] -- then it MUST
* cancel Timer N.
* https://tools.ietf.org/html/rfc6665#section-4.1.2.2
*/
timerN() {
this.logger.warn(`Timer N expired for SUBSCRIBE dialog. Timed out waiting for NOTIFY.`);
if (this.subscriptionState !== SubscriptionState.Terminated) {
this.stateTransition(SubscriptionState.Terminated);
this.onTerminated();
}
}
}