Skip to content

Issue: Two critical problems with client-side INVITE dialogs (UAC): malformed CANCEL and inability to retrieve dialog for external cancellation Summary #53

@FedorKiselev76

Description

@FedorKiselev76

While integrating rsipstack as a SIP UAC for an outbound calling system, I encountered two severe issues that prevent correct CANCEL behavior and make client-side dialog management unreliable:

ClientInviteDialog produces malformed CANCEL requests (To: ...;tag=), breaking SIP parsing on servers such as OpenSIPS.

DialogLayer cannot retrieve client-side dialogs after 1xx responses, because the DialogId used internally diverges from the updated DialogId seen in DialogState events.

Both issues prevent the application from cancelling early-stage calls (before 18x) and even cancelling calls after 183/180 reliably.

The details and full reproduction follow.

  1. Malformed CANCEL when to_tag is empty (;tag=)

When calling ClientInviteDialog::cancel(), the current implementation modifies the To header:

cancel_request
.to_header_mut()?
.mut_tag(self.id().to_tag.clone().into())?;

If self.id().to_tag is empty (normal for Calling/Trying phases), this produces:

To: sip:...;tag=

This is syntactically invalid SIP.

OpenSIPS (and others) reject such CANCEL messages:

ERROR:core:parse_to_param: unexpected char [C] in status 13: <<;tag=#15#012>>
ERROR:core:_parse_to: unexpected end of header in state 13
ERROR:core:get_hdr_field: bad to header
ERROR:core:parse_headers: bad header field
ERROR:core:receive_msg: Unable to parse msg received from [IP]

RFC 3261 §9.1

CANCEL must reuse the same To as the original INVITE.
That means no To-tag should be added, even if a 1xx with tag was previously received.

In other words, CANCEL should use:

To: sip:[email protected]

not:

To: sip:[email protected];tag=gKxxxx # not required
To: sip:[email protected];tag= # invalid

Minimal correct fix

Remove the modification of To when building CANCEL:

// REMOVE THIS (causes malformed CANCELs)
// cancel_request
// .to_header_mut()?
// .mut_tag(self.id().to_tag.clone().into())?;

// CANCEL must use To-header from initial INVITE

After removing this line, CANCELs become RFC-compatible and OpenSIPS accepts them in all phases, including immediately after 100 Trying.

  1. Client-side dialogs cannot be retrieved via DialogLayer::get_dialog

For server-side INVITE dialogs, DialogLayer::get_or_create_server_invite registers dialogs in:

self.inner.dialogs: HashMap<DialogId, Dialog>

But for client-side INVITE dialogs (ClientInviteDialog), external code cannot reliably retrieve them.

Why?

Because client dialog registration looks like this (from do_invite):

let (dialog, tx) = self.create_client_invite_dialog(...)?;
let id = dialog.id();

self.inner.dialogs.write().unwrap().insert(
id.clone(),
Dialog::ClientInvite(dialog.clone()),
);

At this moment:

DialogId.to_tag == "" (empty)

Later, during process_invite, a 1xx or 2xx response updates the remote tag:

self.update_remote_tag(tag);

So DialogId seen externally via DialogState::Early(id, ...) becomes:

call_id: ...
from_tag: ...
to_tag: "gKxxxxx"

But the key in dialogs stays:

call_id: ...
from_tag: ...
to_tag: "" # never updated

Result: DialogLayer::get_dialog(&DialogId) never finds the client dialog.

Example log:

DialogState::Early DialogId { ..., to_tag: "gK04b9f41b" }
[end] ... dialog_id = DialogId { to_tag: "gK04b9f41b" }
hangup_by_call_id: dialog not found in dialog_layer for DialogId { to_tag: "gK04b9f41b" }

This makes it impossible for application code to cancel calls externally (Dialog::ClientInvite(d).cancel()), even after 183/180.

Workaround (not ideal)

I must:

store the initial DialogId from the Calling state (where to_tag == ""),

and never update it during Early/Confirmed.

This works only because the dialog is stored in dialogs under the initial DialogId.

Why this is problematic

DialogId is supposed to uniquely identify a dialog; users expect DialogState to contain the correct DialogId.

After 18x, the SIP dialog is legally identified with the new to_tag.

But DialogLayer still indexes dialogs by the old DialogId (without tag).

This prevents any external dialog manipulation (cancel/bye) via the public API.

Suggested improvements
Fix 1 — Do not modify To header in CANCEL

(small, safe, RFC-correct)

Remove:

cancel_request
.to_header_mut()?
.mut_tag(self.id().to_tag.clone().into())?;

CANCEL should reuse the To-header from the original INVITE.

Fix 2 — Stable dialog lookup for UAC dialogs

One of these options should be adopted:

Option A (minimal): Document behavior

Document that DialogId for UAC dialogs must be taken from DialogState::Calling and not overwritten by Early/Confirmed.
And that only the initial DialogId can be used with DialogLayer::get_dialog.

Option B (better): Make DialogId stable

Either:

change the HashMap key to (Call-ID, from-tag) only, ignoring to_tag,
or

update the key inside dialogs when update_remote_tag() is invoked.

Option C (best): Add a dedicated lookup API

E.g.:

impl DialogLayer {
pub fn get_uac_dialog_by_call_id(&self, call_id: &str) -> Option;
}

This would allow external cancellation logic without relying on the evolving DialogId.

Conclusion

These two issues combined mean:

CANCEL is malformed in early phases → rejected by SIP servers.

External cancellation (d.cancel()) does not work because UAC dialogs are not discoverable after 18x.

After applying:

CANCEL To-header fix, and

Stable ID or proper dialog lookup,

client-side INVITE cancellation becomes fully functional and RFC-compliant.

Happy to submit a PR if maintainers approve the approach.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions