1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
//! `OutboundCallBuilder` — SIP_API_DESIGN_2 §3.3 INVITE builder.
use std::{fmt, sync::Arc};
use rvoip_sip_core::types::Method;
use crate::api::handle::CallId;
use crate::api::headers::{BuilderHeaderState, SipRequestOptions};
use crate::api::unified::UnifiedCoordinator;
use crate::auth::SipClientAuth;
use crate::errors::Result;
use crate::types::Credentials;
/// Per-request override for the `P-Asserted-Identity` (RFC 3325).
///
/// `Debug` reports the selected variant without formatting the URI override.
#[non_exhaustive]
#[derive(Default, Clone)]
pub enum PaiOverride {
/// Inherit `Config.pai_uri`.
#[default]
Default,
/// Suppress PAI emission even if `Config` has one.
Suppress,
/// Override `Config.pai_uri` for this call only.
Use(String),
}
impl fmt::Debug for PaiOverride {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::Default => "Default",
Self::Suppress => "Suppress",
Self::Use(_) => "Use",
})
}
}
/// Per-request override for the outbound proxy `Route:` header.
///
/// `Debug` reports the selected variant without formatting the URI override.
#[non_exhaustive]
#[derive(Default, Clone)]
pub enum ProxyOverride {
/// Inherit `Config.outbound_proxy_uri`.
#[default]
Default,
/// Suppress the outbound proxy `Route:` even if `Config` has one.
Suppress,
/// Override the outbound proxy `Route:` for this call only.
Use(String),
}
impl fmt::Debug for ProxyOverride {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::Default => "Default",
Self::Suppress => "Suppress",
Self::Use(_) => "Use",
})
}
}
/// SIP_API_DESIGN_2 §7.1 — frozen snapshot of an `OutboundCallBuilder`
/// staged on `SessionState.pending_invite_options` and consumed by the
/// `Action::SendINVITEWithOptions` handler.
///
/// `OutboundCallOptions` is an rvoip-sip-side struct (not in
/// rvoip-sip-dialog) because INVITE carries rvoip-sip concerns rvoip-sip-dialog
/// doesn't need: PAI mode, credentials, transfer-leg tracking,
/// `supported_100rel`. The state machine unpacks it at the DialogAdapter
/// boundary into rvoip-sip-dialog's structural `InviteRequestOptions`.
///
/// `Debug` intentionally exposes only operational flags and counts; retained
/// URIs, SDP, credentials, authorization, and application headers are redacted.
#[derive(Default, Clone)]
pub struct OutboundCallOptionsSnapshot {
/// `From:` URI; falls back to `Config.local_uri` when `None`.
pub from: Option<String>,
/// Request-URI / `To:` target of the INVITE.
pub to: String,
/// SDP offer body, if any.
pub sdp: Option<String>,
/// Digest credentials used for 401/407 retry.
pub credentials: Option<Credentials>,
/// General SIP auth used for 401/407 retry.
pub auth: Option<SipClientAuth>,
/// `P-Asserted-Identity` (RFC 3325) override mode for this call.
pub pai_override: PaiOverride,
/// `Contact:` URI override advertised on the INVITE.
pub contact_uri: Option<String>,
/// Outbound proxy `Route:` override mode for this call.
pub outbound_proxy_override: ProxyOverride,
/// `Subject:` header value.
pub subject: Option<String>,
/// `From:` display name override.
pub from_display: Option<String>,
/// Pre-computed `Authorization:` header value, bypassing 401-driven
/// digest computation.
pub precomputed_auth: Option<String>,
/// When set, marks this INVITE as the B leg of an attended transfer
/// initiated by the named transferor session.
pub transfer_leg: Option<CallId>,
/// Whether RFC 3262 reliable provisional responses are advertised.
pub supported_100rel: bool,
/// Application-staged extra headers appended after stack-managed ones.
pub extra_headers: Vec<rvoip_sip_core::types::TypedHeader>,
/// When true, the outbound INVITE applies SBC topology hiding:
/// stack-managed Via headers below the top entry are stripped
/// and Record-Route entries not pointing at this SBC are
/// removed before send. Default `false` — applications that
/// want B2BUA-style hiding turn this on per call via
/// [`OutboundCallBuilder::with_topology_hiding`].
pub topology_hiding: bool,
}
impl fmt::Debug for OutboundCallOptionsSnapshot {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let pai_override = match &self.pai_override {
PaiOverride::Default => "default",
PaiOverride::Suppress => "suppress",
PaiOverride::Use(_) => "override",
};
let outbound_proxy_override = match &self.outbound_proxy_override {
ProxyOverride::Default => "default",
ProxyOverride::Suppress => "suppress",
ProxyOverride::Use(_) => "override",
};
formatter
.debug_struct("OutboundCallOptionsSnapshot")
.field("from_present", &self.from.is_some())
.field("target_present", &!self.to.is_empty())
.field("sdp_present", &self.sdp.is_some())
.field("credentials_present", &self.credentials.is_some())
.field("auth_present", &self.auth.is_some())
.field("pai_override", &pai_override)
.field("contact_uri_present", &self.contact_uri.is_some())
.field("outbound_proxy_override", &outbound_proxy_override)
.field("subject_present", &self.subject.is_some())
.field("from_display_present", &self.from_display.is_some())
.field("precomputed_auth_present", &self.precomputed_auth.is_some())
.field("transfer_leg_present", &self.transfer_leg.is_some())
.field("supported_100rel", &self.supported_100rel)
.field("extra_header_count", &self.extra_headers.len())
.field("topology_hiding", &self.topology_hiding)
.finish()
}
}
/// Outbound INVITE builder.
pub struct OutboundCallBuilder {
coord: Arc<UnifiedCoordinator>,
from: Option<String>,
to: String,
sdp: Option<String>,
credentials: Option<Credentials>,
auth: Option<SipClientAuth>,
pai: PaiOverride,
contact_uri: Option<String>,
outbound_proxy: ProxyOverride,
subject: Option<String>,
from_display: Option<String>,
precomputed_authorization: Option<String>,
transfer_leg: Option<CallId>,
supported_100rel: bool,
state: BuilderHeaderState,
topology_hiding: bool,
session_id: Option<CallId>,
}
impl OutboundCallBuilder {
pub(crate) fn new(
coord: Arc<UnifiedCoordinator>,
from: Option<String>,
to: impl Into<String>,
) -> Self {
Self {
coord,
from,
to: to.into(),
sdp: None,
credentials: None,
auth: None,
pai: PaiOverride::default(),
contact_uri: None,
outbound_proxy: ProxyOverride::default(),
subject: None,
from_display: None,
precomputed_authorization: None,
transfer_leg: None,
supported_100rel: false,
state: BuilderHeaderState::default(),
topology_hiding: false,
session_id: None,
}
}
/// Attach an SDP offer.
pub fn with_sdp(mut self, sdp: impl Into<String>) -> Self {
self.sdp = Some(sdp.into());
self
}
/// Send the initial INVITE without an SDP offer.
///
/// If the successful response contains an SDP offer, the coordinator
/// generates its answer and carries it in ACK as required by RFC 3261.
pub fn without_sdp(mut self) -> Self {
// `None` means "use the generated offer" at dispatch, so retain an
// explicit empty snapshot to represent delayed offer.
self.sdp = Some(String::new());
self
}
/// Attach Digest credentials for UAC 401/407 retry.
pub fn with_credentials(mut self, creds: Credentials) -> Self {
self.credentials = Some(creds);
self
}
/// Attach general UAC SIP auth for 401/407 retry.
///
/// Use [`SipClientAuth::any`] when the peer may offer multiple schemes and
/// the UAC should negotiate among Digest, Bearer, Basic, and AKA options.
pub fn with_auth(mut self, auth: SipClientAuth) -> Self {
self.auth = Some(auth);
self
}
/// Attach a Bearer token for UAC 401/407 retry.
pub fn with_bearer_token(mut self, token: impl Into<String>) -> Self {
self.auth = Some(SipClientAuth::bearer_token(token));
self
}
/// Attach Basic credentials for UAC 401/407 retry.
///
/// Basic is cleartext-disabled by default. Use
/// `with_auth(SipClientAuth::basic(...).allow_basic_over_cleartext(true))`
/// only for explicit legacy cleartext interop.
pub fn with_basic_credentials(
mut self,
username: impl Into<String>,
password: impl Into<String>,
) -> Self {
self.auth = Some(SipClientAuth::basic(username, password));
self
}
/// Override the `P-Asserted-Identity` URI for this call only.
pub fn with_pai(mut self, uri: impl Into<String>) -> Self {
self.pai = PaiOverride::Use(uri.into());
self
}
/// Suppress `P-Asserted-Identity` emission even when
/// `Config.pai_uri` is set.
pub fn without_pai(mut self) -> Self {
self.pai = PaiOverride::Suppress;
self
}
/// Override the `Contact:` URI advertised on this INVITE.
pub fn with_contact_uri(mut self, uri: impl Into<String>) -> Self {
self.contact_uri = Some(uri.into());
self
}
/// Override the outbound proxy `Route:` for this call only.
pub fn with_outbound_proxy(mut self, uri: impl Into<String>) -> Self {
self.outbound_proxy = ProxyOverride::Use(uri.into());
self
}
/// Suppress the outbound proxy `Route:` even when
/// `Config.outbound_proxy_uri` is set.
pub fn without_outbound_proxy(mut self) -> Self {
self.outbound_proxy = ProxyOverride::Suppress;
self
}
/// Attach a `Subject:` header.
pub fn with_subject(mut self, subject: impl Into<String>) -> Self {
self.subject = Some(subject.into());
self
}
/// Override the `From:` display name.
pub fn with_from_display(mut self, display: impl Into<String>) -> Self {
self.from_display = Some(display.into());
self
}
/// Pre-computed `Authorization:` header value — bypasses
/// 401-driven digest computation.
pub fn with_precomputed_authorization(mut self, value: impl Into<String>) -> Self {
self.precomputed_authorization = Some(value.into());
self
}
/// Mark this INVITE as the B leg of a `transferor`-initiated
/// attended transfer (used for media bridging + REFER-completion
/// NOTIFY).
pub fn as_transfer_leg(mut self, transferor: &CallId) -> Self {
self.transfer_leg = Some(transferor.clone());
self
}
/// Advertise RFC 3262 reliable provisional support.
pub fn with_supported_100rel(mut self, supported: bool) -> Self {
self.supported_100rel = supported;
self
}
/// Apply SBC topology hiding to this outbound INVITE. When enabled,
/// any Via headers below the SBC's own top Via are stripped, and
/// Record-Route entries that do not point at this SBC are removed
/// before the message reaches the wire. Use this on B2BUA forwards
/// where the inbound topology (upstream Via stack, intermediate
/// Record-Routes) must not leak to downstream peers.
///
/// The default outbound-INVITE shape already builds a fresh Via
/// stack and Contact, so most call sites do not need this — the
/// flag matters only for forward paths that explicitly carry
/// inherited topology headers across (e.g. proxy-style B2BUA on
/// top of `Transport::send_message_raw`).
pub fn with_topology_hiding(mut self, enabled: bool) -> Self {
self.topology_hiding = enabled;
self
}
/// Use a caller-reserved Session identity for this INVITE.
///
/// This crate-private seam lets the core adapter install its Connection ID
/// and dormant event stage before the state machine can emit a fast answer
/// or terminal event. Ordinary API callers continue to receive a generated
/// Session ID from [`Self::send`].
pub(crate) fn with_reserved_session_id(mut self, session_id: CallId) -> Self {
self.session_id = Some(session_id);
self
}
/// Send the INVITE.
///
/// Routes through the unified state-machine path: creates the
/// session, stages an [`OutboundCallOptionsSnapshot`] on the
/// session's INVITE stash, then dispatches
/// [`EventType::SendOutboundInvite`](crate::state_table::EventType::SendOutboundInvite).
/// The state table's `(Idle, SendOutboundInvite, UAC)` row runs
/// `CreateDialog → CreateMediaSession → GenerateLocalSDP →
/// SendINVITEWithOptions`, which drains the stash and emits the
/// INVITE through rvoip-sip-dialog's `send_invite_with_extra_headers`.
/// Application-staged headers, PAI override, outbound-proxy
/// override, credentials and Subject ride through the snapshot.
pub async fn send(self) -> Result<CallId> {
#[cfg(feature = "perf-call-setup-diagnostics")]
let send_started = std::time::Instant::now();
let from = self
.from
.clone()
.unwrap_or_else(|| self.coord.config_local_uri());
let to = self.to.clone();
// Resolve PAI per the builder's override mode against Config.
let pai_uri = match &self.pai {
PaiOverride::Use(uri) => Some(uri.clone()),
PaiOverride::Suppress => None,
PaiOverride::Default => self.coord.config_pai_uri(),
};
// Fall back to `Config::credentials` when the application did
// not stage per-call credentials, so PBX-auth flows that only
// configure peer-level credentials keep working.
let credentials = self
.credentials
.clone()
.or_else(|| self.coord.config_credentials());
let auth = self
.auth
.clone()
.or_else(|| self.coord.config_auth())
.or_else(|| credentials.clone().map(Into::into));
// Build the snapshot — folds every override into a frozen
// struct that the state-machine handler reads back verbatim.
let snapshot = std::sync::Arc::new(OutboundCallOptionsSnapshot {
from: Some(from.clone()),
to: to.clone(),
sdp: self.sdp,
credentials,
auth,
pai_override: self.pai,
contact_uri: self.contact_uri,
outbound_proxy_override: self.outbound_proxy,
subject: self.subject,
from_display: self.from_display,
precomputed_auth: self.precomputed_authorization,
transfer_leg: self.transfer_leg,
supported_100rel: self.supported_100rel,
extra_headers: self.state.headers.clone(),
topology_hiding: self.topology_hiding,
});
// Validate the complete wire-facing option set before allocating a
// SessionState or media resources. This catches semantic stack-owned
// headers (including `TypedHeader::Other` aliases), malformed Contact,
// auth, proxy and singleton collisions without mutating any runtime
// state or performing DNS.
let (preflight_opts, _) = crate::state_machine::actions::materialize_invite_options(
&snapshot,
pai_uri.as_deref(),
snapshot.sdp.clone(),
)
.map_err(|error| crate::errors::SessionError::InvalidInput(error.to_string()))?;
rvoip_sip_dialog::api::unified::validate_initial_invite_options(&preflight_opts).map_err(
|_| {
crate::errors::SessionError::InvalidInput(
"initial INVITE options failed preflight validation".to_string(),
)
},
)?;
// Create the session up front — Idle UAC. Builder metadata and the
// immutable request snapshot enter together through the exact-session
// lane below, before the state-table `CreateDialog` action reads them.
let session_id = self.session_id.unwrap_or_default();
#[cfg(feature = "perf-call-setup-diagnostics")]
let create_session_started = std::time::Instant::now();
self.coord
.helpers
.create_session(
session_id.clone(),
from.clone(),
to.clone(),
crate::state_table::Role::UAC,
)
.await?;
#[cfg(feature = "perf-call-setup-diagnostics")]
crate::call_setup_diag::record_stage(
&session_id,
"outbound_send.create_session",
create_session_started.elapsed(),
);
let setup_result: Result<()> = async {
#[cfg(feature = "perf-call-setup-diagnostics")]
let stage_and_dispatch_started = std::time::Instant::now();
self.coord
.dispatch_outbound_invite_with_options(&session_id, Arc::clone(&snapshot), pai_uri)
.await?;
#[cfg(feature = "perf-call-setup-diagnostics")]
crate::call_setup_diag::record_stage(
&session_id,
"outbound_send.stage_and_dispatch",
stage_and_dispatch_started.elapsed(),
);
Ok(())
}
.await;
if let Err(error) = setup_result {
self.coord.rollback_outbound_setup(&session_id).await;
return Err(error);
}
#[cfg(feature = "perf-call-setup-diagnostics")]
let schedule_timeout_started = std::time::Instant::now();
self.coord
.schedule_outbound_setup_timeout(&session_id)
.await;
#[cfg(feature = "perf-call-setup-diagnostics")]
{
crate::call_setup_diag::record_stage(
&session_id,
"outbound_send.schedule_timeout",
schedule_timeout_started.elapsed(),
);
crate::call_setup_diag::record_stage(
&session_id,
"outbound_send.total",
send_started.elapsed(),
);
}
Ok(session_id)
}
}
impl SipRequestOptions for OutboundCallBuilder {
fn method(&self) -> Method {
Method::Invite
}
fn header_state_mut(&mut self) -> &mut BuilderHeaderState {
&mut self.state
}
fn header_state(&self) -> &BuilderHeaderState {
&self.state
}
}
#[cfg(test)]
mod tests {
use super::*;
use rvoip_sip_core::types::{headers::HeaderValue, HeaderName, TypedHeader};
#[test]
fn outbound_call_snapshot_debug_redacts_every_retained_value() {
const SECRET: &str = "outbound-snapshot-secret-canary";
let snapshot = OutboundCallOptionsSnapshot {
from: Some(format!("sip:{SECRET}@from.invalid")),
to: format!("sip:{SECRET}@target.invalid"),
sdp: Some(format!("v=0\r\na={SECRET}")),
credentials: Some(Credentials::new(SECRET, SECRET)),
auth: Some(SipClientAuth::bearer_token(SECRET)),
pai_override: PaiOverride::Use(format!("sip:{SECRET}@pai.invalid")),
contact_uri: Some(format!("sip:{SECRET}@contact.invalid")),
outbound_proxy_override: ProxyOverride::Use(format!("sip:{SECRET}@proxy.invalid")),
subject: Some(SECRET.into()),
from_display: Some(SECRET.into()),
precomputed_auth: Some(format!("Bearer {SECRET}")),
transfer_leg: Some(SECRET.into()),
supported_100rel: true,
extra_headers: vec![TypedHeader::Other(
HeaderName::Other("X-Secret-Canary".into()),
HeaderValue::Raw(SECRET.as_bytes().to_vec()),
)],
topology_hiding: true,
};
let debug = format!("{snapshot:?}");
assert!(!debug.contains(SECRET));
assert!(!debug.contains("X-Secret-Canary"));
assert!(debug.contains("credentials_present: true"));
assert!(debug.contains("auth_present: true"));
assert!(debug.contains("pai_override: \"override\""));
assert!(debug.contains("outbound_proxy_override: \"override\""));
assert!(debug.contains("extra_header_count: 1"));
assert!(debug.contains("supported_100rel: true"));
let pai_debug = format!("{:?}", PaiOverride::Use(SECRET.into()));
let proxy_debug = format!("{:?}", ProxyOverride::Use(SECRET.into()));
assert_eq!(pai_debug, "Use");
assert_eq!(proxy_debug, "Use");
assert!(!pai_debug.contains(SECRET));
assert!(!proxy_debug.contains(SECRET));
}
}