From 13b82174030fd1ea378e526de8e594d13ad303eb Mon Sep 17 00:00:00 2001 From: zaphod534 Date: Wed, 19 Jun 2024 10:59:56 +0200 Subject: [PATCH] add incoming update requests handling --- src/api/invitation.ts | 3 +- src/api/inviter.ts | 6 +- src/api/session-delegate.ts | 8 ++ src/api/session.ts | 75 +++++++++++++++++++ src/core/dialogs/session-dialog.ts | 54 +++++++++++++ src/core/messages/methods/index.ts | 1 + src/core/messages/methods/update.ts | 22 ++++++ src/core/session/session-delegate.ts | 7 ++ src/core/user-agent-core/allowed-methods.ts | 3 +- .../user-agents/update-user-agent-server.ts | 39 ++++++++++ src/grammar/pegjs/src/grammar.pegjs | 4 +- 11 files changed, 217 insertions(+), 5 deletions(-) create mode 100644 src/core/messages/methods/update.ts create mode 100644 src/core/user-agents/update-user-agent-server.ts diff --git a/src/api/invitation.ts b/src/api/invitation.ts index 943406c15..a64eecbd6 100644 --- a/src/api/invitation.ts +++ b/src/api/invitation.ts @@ -270,7 +270,8 @@ export class Invitation extends Session { onMessage: (messageRequest): void => this.onMessageRequest(messageRequest), onNotify: (notifyRequest): void => this.onNotifyRequest(notifyRequest), onPrack: (prackRequest): void => this.onPrackRequest(prackRequest), - onRefer: (referRequest): void => this.onReferRequest(referRequest) + onRefer: (referRequest): void => this.onReferRequest(referRequest), + onUpdate: (updateRequest): void => this.onUpdateRequest(updateRequest) }; this._dialog = session; this.stateTransition(SessionState.Established); diff --git a/src/api/inviter.ts b/src/api/inviter.ts index f04717b9e..1f637b798 100644 --- a/src/api/inviter.ts +++ b/src/api/inviter.ts @@ -136,7 +136,8 @@ export class Inviter extends Session { } extraHeaders.push("Contact: " + contact); extraHeaders.push( - "Allow: " + ["ACK", "CANCEL", "INVITE", "MESSAGE", "BYE", "OPTIONS", "INFO", "NOTIFY", "REFER"].toString() + "Allow: " + + ["ACK", "CANCEL", "INVITE", "MESSAGE", "BYE", "OPTIONS", "INFO", "NOTIFY", "REFER", "UPDATE"].toString() ); if (userAgent.configuration.sipExtension100rel === SIPExtension.Required) { extraHeaders.push("Require: 100rel"); @@ -810,7 +811,8 @@ export class Inviter extends Session { onMessage: (messageRequest): void => this.onMessageRequest(messageRequest), onNotify: (notifyRequest): void => this.onNotifyRequest(notifyRequest), onPrack: (prackRequest): void => this.onPrackRequest(prackRequest), - onRefer: (referRequest): void => this.onReferRequest(referRequest) + onRefer: (referRequest): void => this.onReferRequest(referRequest), + onUpdate: (updateRequest): void => this.onUpdateRequest(updateRequest) }; this._dialog = session; diff --git a/src/api/session-delegate.ts b/src/api/session-delegate.ts index e8b97ebaa..35b9ed9b9 100644 --- a/src/api/session-delegate.ts +++ b/src/api/session-delegate.ts @@ -72,6 +72,14 @@ export interface SessionDelegate { */ onRefer?(referral: Referral): void; + /** + * Called upon receiving an incoming in dialog UPDATE request. + * @param request - The incoming update request. + * @param response - The response sent + * @param statusCode - The status code for the outgoing update response. + */ + onUpdate?(request: IncomingRequestMessage, response: string, statusCode: number): void; + /** * Called upon creating a SessionDescriptionHandler. * diff --git a/src/api/session.ts b/src/api/session.ts index 0eb30121a..b1174996b 100644 --- a/src/api/session.ts +++ b/src/api/session.ts @@ -50,6 +50,7 @@ import { SessionOptions } from "./session-options.js"; import { SessionReferOptions } from "./session-refer-options.js"; import { SessionState } from "./session-state.js"; import { UserAgent } from "./user-agent.js"; +import { IncomingUpdateRequest } from "../core/index.js"; /** * A session provides real time communication between one or more participants. @@ -1099,6 +1100,80 @@ export abstract class Session { } } + protected onUpdateRequest(request: IncomingUpdateRequest): void { + this.logger.log("Session.onUpdateRequest"); + + const extraHeaders = ["Contact: " + this._contact]; + + // Handle P-Asserted-Identity + if (request.message.hasHeader("P-Asserted-Identity")) { + const header = request.message.getHeader("P-Asserted-Identity"); + if (!header) { + throw new Error("Header undefined."); + } + this._assertedIdentity = Grammar.nameAddrHeaderParse(header); + } + + const body = getBody(request.message); + const hasOffer = body && body.contentDisposition === "session"; + + if (!hasOffer) { + const response = request.accept({ statusCode: 200, extraHeaders }); + this.delegate?.onUpdate?.(request.message, response.message, 200); + return; + } + + const options = { + sessionDescriptionHandlerOptions: this.sessionDescriptionHandlerOptionsReInvite, + sessionDescriptionHandlerModifiers: this.sessionDescriptionHandlerModifiersReInvite + }; + + this.generateResponseOfferAnswerInDialog(options) + .then((body) => { + const response = request.accept({ statusCode: 200, extraHeaders, body }); + this.delegate?.onUpdate?.(request.message, response.message, 200); + }) + .catch((error: Error) => { + this.logger.error(error.message); + this.logger.error("Failed to handle to UPDATE request"); + if (!this.dialog) { + throw new Error("Dialog undefined."); + } + this.logger.error(this.dialog.signalingState); + // If we don't have a local/remote offer... + if (this.dialog.signalingState === SignalingState.Stable) { + const response = request.reject({ statusCode: 488 }); // Not Acceptable Here + this.delegate?.onUpdate?.(request.message, response.message, 488); + return; + } + // Otherwise rollback + this.rollbackOffer() + .then(() => { + const response = request.reject({ statusCode: 488 }); // Not Acceptable Here + this.delegate?.onUpdate?.(request.message, response.message, 488); + }) + .catch((errorRollback: Error) => { + // No way to recover, so terminate session and mark as failed. + this.logger.error(errorRollback.message); + this.logger.error("Failed to rollback offer on UPDATE request"); + const response = request.reject({ statusCode: 488 }); // Not Acceptable Here + // A BYE should only be sent if session is not already terminated. + // For example, a BYE may be sent/received while re-INVITE is outstanding. + // Note that the ACK was already sent by the transaction, so just need to send BYE. + if (this.state !== SessionState.Terminated) { + if (!this.dialog) { + throw new Error("Dialog undefined."); + } + const extraHeadersBye: Array = []; + extraHeadersBye.push("Reason: " + this.getReasonHeaderValue(500, "Internal Server Error")); + this.dialog.bye(undefined, { extraHeaders: extraHeadersBye }); + this.stateTransition(SessionState.Terminated); + } + this.delegate?.onUpdate?.(request.message, response.message, 488); + }); + }); + } + /** * Generate an offer or answer for a response to an INVITE request. * If a remote offer was provided in the request, set the remote diff --git a/src/core/dialogs/session-dialog.ts b/src/core/dialogs/session-dialog.ts index fcc08b604..17a06fc49 100644 --- a/src/core/dialogs/session-dialog.ts +++ b/src/core/dialogs/session-dialog.ts @@ -36,6 +36,7 @@ import { ReInviteUserAgentClient } from "../user-agents/re-invite-user-agent-cli import { ReInviteUserAgentServer } from "../user-agents/re-invite-user-agent-server.js"; import { ReferUserAgentClient } from "../user-agents/refer-user-agent-client.js"; import { ReferUserAgentServer } from "../user-agents/refer-user-agent-server.js"; +import { UpdateUserAgentServer } from "../user-agents/update-user-agent-server.js"; import { Dialog } from "./dialog.js"; import { DialogState } from "./dialog-state.js"; @@ -48,6 +49,7 @@ export class SessionDialog extends Dialog implements Session { public reinviteUserAgentClient: ReInviteUserAgentClient | undefined; public reinviteUserAgentServer: ReInviteUserAgentServer | undefined; + public updateUserAgentServer: UpdateUserAgentServer | undefined; /** The state of the offer/answer exchange. */ private _signalingState: SignalingState = SignalingState.Initial; @@ -615,6 +617,45 @@ export class SessionDialog extends Dialog implements Session { this.dialogState.remoteTarget = contact.uri; } + if (message.method === C.UPDATE) { + const retryAfter = Math.floor(Math.random() * 10) + 1; + const extraHeaders = [`Retry-After: ${retryAfter}`]; + + const body = getBody(message); + const hasOffer = body && body.contentDisposition === "session"; + + if (hasOffer && this._signalingState === SignalingState.HaveLocalOffer) { + // If an UPDATE is received that contains an offer, and the UAS has + // generated an offer (in an UPDATE, PRACK or INVITE) to which it has + // not yet received an answer, the UAS MUST reject the UPDATE with a 491 + // response + // https://datatracker.ietf.org/doc/html/rfc3311#section-5.2 + this.core.replyStateless(message, { statusCode: 491 }); + return; + } + + if (hasOffer && this._signalingState === SignalingState.HaveRemoteOffer) { + // Similarly, if an UPDATE is received that contains an + // offer, and the UAS has received an offer (in an UPDATE, PRACK, or + // INVITE) to which it has not yet generated an answer, the UAS MUST + // reject the UPDATE with a 500 response, and MUST include a Retry-After + // header field with a randomly chosen value between 0 and 10 seconds. + // https://datatracker.ietf.org/doc/html/rfc3311#section-5.2 + this.core.replyStateless(message, { statusCode: 500, extraHeaders }); + return; + } + + if (this.updateUserAgentServer) { + // A UAS that receives an UPDATE before it has generated a final + // response to a previous UPDATE on the same dialog MUST return a 500 + // response to the new UPDATE, and MUST include a Retry-After header + // field with a randomly chosen value between 0 and 10 seconds. + // https://datatracker.ietf.org/doc/html/rfc3311#section-5.2 + this.core.replyStateless(message, { statusCode: 500, extraHeaders }); + return; + } + } + // Switch on method and then delegate. switch (message.method) { case C.BYE: @@ -692,6 +733,19 @@ export class SessionDialog extends Dialog implements Session { this.delegate && this.delegate.onRefer ? this.delegate.onRefer(uas) : uas.reject(); } break; + case C.UPDATE: + // However, unlike a re-INVITE, the UPDATE MUST be + // responded to promptly, and therefore the user cannot generally be + // prompted to approve the session changes. If the UAS cannot change + // the session parameters without prompting the user, it SHOULD reject + // the request with a 504 response. + // https://datatracker.ietf.org/doc/html/rfc3311#section-5.2 + { + const uas = new UpdateUserAgentServer(this, message); + this.signalingStateTransition(message); + this.delegate && this.delegate.onUpdate ? this.delegate.onUpdate(uas) : uas.reject({ statusCode: 504 }); + } + break; default: { this.logger.log(`INVITE dialog ${this.id} received unimplemented ${message.method} request`); diff --git a/src/core/messages/methods/index.ts b/src/core/messages/methods/index.ts index fa3e3d1dd..8ec29e3db 100644 --- a/src/core/messages/methods/index.ts +++ b/src/core/messages/methods/index.ts @@ -11,3 +11,4 @@ export * from "./publish.js"; export * from "./register.js"; export * from "./refer.js"; export * from "./subscribe.js"; +export * from "./update.js"; diff --git a/src/core/messages/methods/update.ts b/src/core/messages/methods/update.ts new file mode 100644 index 000000000..8019f4c3c --- /dev/null +++ b/src/core/messages/methods/update.ts @@ -0,0 +1,22 @@ +/* eslint-disable @typescript-eslint/no-empty-interface */ +import { IncomingRequest } from "../incoming-request.js"; +import { IncomingResponse } from "../incoming-response.js"; +import { OutgoingRequest } from "../outgoing-request.js"; + +/** + * Incoming UPDATE request. + * @public + */ +export interface IncomingUpdateRequest extends IncomingRequest {} + +/** + * Incoming UPDATE response. + * @public + */ +export interface IncomingUpdateResponse extends IncomingResponse {} + +/** + * Outgoing UPDATE request. + * @public + */ +export interface OutgoingUpdateRequest extends OutgoingRequest {} diff --git a/src/core/session/session-delegate.ts b/src/core/session/session-delegate.ts index 4f28a72d1..e9880fe05 100644 --- a/src/core/session/session-delegate.ts +++ b/src/core/session/session-delegate.ts @@ -1,3 +1,4 @@ +import { IncomingUpdateRequest } from "../messages/methods/update.js"; import { IncomingAckRequest } from "../messages/methods/ack.js"; import { IncomingByeRequest } from "../messages/methods/bye.js"; import { IncomingInfoRequest } from "../messages/methods/info.js"; @@ -83,4 +84,10 @@ export interface SessionDelegate { * @param request - Incoming REFER request. */ onRefer?(request: IncomingReferRequest): void; + + /** + * Receive UPDATE request. + * @param request - Incoming UPDATE request. + */ + onUpdate?(request: IncomingUpdateRequest): void; } diff --git a/src/core/user-agent-core/allowed-methods.ts b/src/core/user-agent-core/allowed-methods.ts index 5cc45349e..1e1adc7bc 100644 --- a/src/core/user-agent-core/allowed-methods.ts +++ b/src/core/user-agent-core/allowed-methods.ts @@ -15,5 +15,6 @@ export const AllowedMethods = [ C.PRACK, // FIXME: Only if 100rel Supported C.REFER, C.REGISTER, - C.SUBSCRIBE + C.SUBSCRIBE, + C.UPDATE ]; diff --git a/src/core/user-agents/update-user-agent-server.ts b/src/core/user-agents/update-user-agent-server.ts new file mode 100644 index 000000000..907ae8274 --- /dev/null +++ b/src/core/user-agents/update-user-agent-server.ts @@ -0,0 +1,39 @@ +import { SessionDialog } from "../dialogs/session-dialog.js"; +import { IncomingRequestDelegate } from "../messages/incoming-request.js"; +import { IncomingRequestMessage } from "../messages/incoming-request-message.js"; +import { IncomingUpdateRequest } from "../messages/methods/update.js"; +import { NonInviteServerTransaction } from "../transactions/non-invite-server-transaction.js"; +import { UserAgentServer } from "./user-agent-server.js"; +import { OutgoingResponse, ResponseOptions } from "../messages/outgoing-response.js"; + +/** + * UPDATE UAS. + * @public + */ +export class UpdateUserAgentServer extends UserAgentServer implements IncomingUpdateRequest { + private dialog: SessionDialog; + + constructor(dialog: SessionDialog, message: IncomingRequestMessage, delegate?: IncomingRequestDelegate) { + super(NonInviteServerTransaction, dialog.userAgentCore, message, delegate); + dialog.updateUserAgentServer = this; + this.dialog = dialog; + } + + public accept(options: ResponseOptions = { statusCode: 200 }): OutgoingResponse { + const response = super.accept(options); + this.dialog.updateUserAgentServer = undefined; + + if (options.body) { + // Update dialog signaling state with offer/answer in body + this.dialog.signalingStateTransition(options.body); + } + + return response; + } + + public reject(options: ResponseOptions = { statusCode: 504 }): OutgoingResponse { + this.dialog.signalingStateRollback(); + this.dialog.updateUserAgentServer = undefined; + return super.reject(options); + } +} diff --git a/src/grammar/pegjs/src/grammar.pegjs b/src/grammar/pegjs/src/grammar.pegjs index 3c1f564b6..50520af48 100644 --- a/src/grammar/pegjs/src/grammar.pegjs +++ b/src/grammar/pegjs/src/grammar.pegjs @@ -370,8 +370,10 @@ REFERm = "\x52\x45\x46\x45\x52" // REFER in caps PUBLISHm = "\x50\x55\x42\x4c\x49\x53\x48" // PUBLISH in caps +UPDATEm = "\x55\x50\x44\x41\x54\x45" // UPDATE in caps + Method = ( INVITEm / ACKm / OPTIONSm / BYEm / CANCELm / REGISTERm - / SUBSCRIBEm / PUBLISHm / NOTIFYm / REFERm / extension_method ){ + / SUBSCRIBEm / PUBLISHm / NOTIFYm / REFERm / UPDATEm / extension_method ){ options = options || { data: {}}; options.data.method = text();