sipx_media/ice/negotiate.rs
1//! Reading a peer's description: is ICE on for this stream at all (RFC 8839 §5.3, §6; [spec]
2//! §13.2, §13.3)?
3//!
4//! Three answers and no fourth, because the driver has to branch on exactly one of them before it
5//! binds anything to the media port:
6//!
7//! - the peer sent no `a=candidate`, so ICE is off and symmetric RTP carries the call as it does
8//! today — RFC 8839 §6: "An agent can determine that its peer supports ICE by the presence of
9//! 'candidate' attributes for each media session";
10//! - the peer sent candidates and they line up with where it says to send, so ICE is on;
11//! - the peer sent candidates and its **default destination** for a component matches none of
12//! them, which is §5.3's `ice-mismatch`: ICE MUST NOT be used for that stream, the answer says
13//! so, and RFC 3264's procedures apply instead.
14//!
15//! Pure SDP. No clock and no socket reach this module, which is why it is scanned by the same
16//! guard as the agent.
17//!
18//! [spec]: https://github.com/codewandler/sipx/blob/main/docs/specs/ice.md
19
20use std::net::SocketAddr;
21
22use sipx_sdp::ice::{Candidate, ComponentId, Credentials};
23use sipx_sdp::{MediaDescription, SessionDescription};
24
25/// The attribute an answer carries when §5.3's condition holds. Media level, answer only.
26pub const ICE_MISMATCH: &str = "ice-mismatch";
27
28/// What a peer's description says about ICE for one stream.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum Negotiation {
31 /// No usable `a=candidate`: the peer is not doing ICE ([spec] §13.3).
32 ///
33 /// Nothing is offered back, no check is sent, no timer runs, and the stream is carried by
34 /// symmetric RTP exactly as it is today. **This is the common case and must stay the common
35 /// case** — a stack that requires ICE to place a call has regressed.
36 ///
37 /// [spec]: https://github.com/codewandler/sipx/blob/main/docs/specs/ice.md
38 Absent,
39 /// ICE is on for this stream, with these parameters.
40 Ice {
41 /// The peer's `a=ice-ufrag` and `a=ice-pwd`, media level winning over session level
42 /// (RFC 8839 §5.4).
43 credentials: Credentials,
44 /// Its `a=candidate` lines, in the order they appeared.
45 candidates: Vec<Candidate>,
46 /// Whether the session carried `a=ice-lite` (§5.3). A full agent facing a lite one
47 /// controls unconditionally (RFC 8445 §6.1.1).
48 lite: bool,
49 },
50 /// RFC 8839 §5.3: the offer's default destination for a component matched none of its
51 /// candidates for that component.
52 ///
53 /// The answer carries [`ICE_MISMATCH`] for the stream and ICE MUST NOT be used for it; the
54 /// stream falls back to RFC 3264 — which is to say to [`Negotiation::Absent`]'s behaviour,
55 /// arrived at by a different route and reported differently, because the offerer needs to
56 /// know that something between the two of us rewrote the address it advertised.
57 Mismatch,
58}
59
60impl Negotiation {
61 /// Whether an agent should be driven for this stream.
62 #[must_use]
63 pub const fn runs_ice(&self) -> bool {
64 matches!(self, Self::Ice { .. })
65 }
66
67 /// The attributes this decision adds to the answer's media section.
68 ///
69 /// Only §5.3's flag today: the ICE attributes an answer carries for a stream that *is* doing
70 /// ICE are the local description's ([`super::LocalDescription::attributes`]), not the
71 /// remote's, so they come from the other side of the exchange.
72 #[must_use]
73 pub fn answer_attributes(&self) -> Vec<sipx_sdp::Attribute> {
74 match self {
75 Self::Mismatch => vec![sipx_sdp::Attribute::flag(ICE_MISMATCH)],
76 Self::Absent | Self::Ice { .. } => Vec::new(),
77 }
78 }
79}
80
81/// Read one stream of a peer's description (RFC 8839 §5.3, §6).
82///
83/// `session` is the whole description because three of the four inputs are session-level or may
84/// be: `a=ice-lite` is session-level only, the credentials default to the session's, and the
85/// `c=` line a stream inherits when it has none of its own is the session's.
86#[must_use]
87pub fn negotiate(session: &SessionDescription, media: &MediaDescription) -> Negotiation {
88 let candidates = media.ice_candidates();
89 if candidates.is_empty() {
90 return Negotiation::Absent;
91 }
92 let Some(credentials) = session.ice_credentials_for(media) else {
93 // Candidates with no `ice-ufrag`/`ice-pwd` for the stream. RFC 8839 §4.2 makes both
94 // mandatory, and without them there is no key for a connectivity check in either
95 // direction, so ICE cannot run whatever the candidates say.
96 //
97 // Deliberately not `Mismatch`: §5.3's flag is a specific diagnosis — "your default
98 // destination was rewritten between us" — and reporting it here would tell the offerer to
99 // look at its NAT when the fault is in its SDP. The fallback is the same either way.
100 tracing::debug!("ice candidates with no credentials; carrying the stream without ice");
101 return Negotiation::Absent;
102 };
103
104 for (component, default) in default_destinations(session, media) {
105 if !candidates
106 .iter()
107 .any(|candidate| matches(candidate, component, default))
108 {
109 tracing::debug!(
110 component = component.get(),
111 %default,
112 "no candidate for the default destination; RFC 8839 §5.3 ice-mismatch"
113 );
114 return Negotiation::Mismatch;
115 }
116 }
117
118 Negotiation::Ice {
119 credentials,
120 candidates,
121 lite: session.is_ice_lite(),
122 }
123}
124
125/// Whether a candidate line *is* this default destination for this component.
126fn matches(candidate: &Candidate, component: ComponentId, default: SocketAddr) -> bool {
127 candidate.component == component
128 && candidate.address == default.ip()
129 && candidate.port == default.port()
130}
131
132/// Where the offer says to send each component if ICE were not used at all.
133///
134/// Component 1 is the `c=`/`m=` pair, the stream's own `c=` winning over the session's. Component
135/// 2 is RFC 3550 §11's convention — the next port up — and is only consulted when the peer offered
136/// candidates for that component: a peer offering RTP alone has no RTCP default to mismatch, and
137/// §6.1.2.2 already reduces the stream to the components both agents have.
138///
139/// `a=rtcp` (RFC 3605) would override the convention and is not parsed by [`sipx_sdp`]; until it
140/// is, the convention is the only default there is, and it is the same one this crate's own RTCP
141/// sender already follows.
142fn default_destinations(
143 session: &SessionDescription,
144 media: &MediaDescription,
145) -> Vec<(ComponentId, SocketAddr)> {
146 let Some(address) = media
147 .connection
148 .as_ref()
149 .or(session.connection.as_ref())
150 .and_then(|connection| connection.address.ip())
151 else {
152 // No `c=` at all, or one naming an FQDN. There is no default destination to compare
153 // against, so there is nothing §5.3 can be true of.
154 return Vec::new();
155 };
156 if media.port == 0 {
157 // A rejected stream (RFC 3264 §6): nothing is sent to it and nothing is mismatched.
158 return Vec::new();
159 }
160
161 let mut defaults = vec![(ComponentId::RTP, SocketAddr::new(address, media.port))];
162 let offers_rtcp = media
163 .ice_candidates()
164 .iter()
165 .any(|candidate| candidate.component == ComponentId::RTCP);
166 // `checked_add`, because a stream on port 65535 has no port above it and therefore no RTCP
167 // default destination at all.
168 if let Some(port) = media.port.checked_add(1).filter(|_| offers_rtcp) {
169 defaults.push((ComponentId::RTCP, SocketAddr::new(address, port)));
170 }
171 defaults
172}
173
174#[cfg(test)]
175#[allow(
176 clippy::unwrap_used,
177 clippy::expect_used,
178 clippy::panic,
179 clippy::indexing_slicing
180)]
181mod tests {
182 use super::*;
183
184 /// An offer whose stream carries `attributes` under the `m=` line, sending to `port` at
185 /// 192.0.2.1.
186 fn offer(port: u16, attributes: &str) -> SessionDescription {
187 let text = format!(
188 concat!(
189 "v=0\r\n",
190 "o=- 1 1 IN IP4 192.0.2.1\r\n",
191 "s=-\r\n",
192 "c=IN IP4 192.0.2.1\r\n",
193 "t=0 0\r\n",
194 "m=audio {port} RTP/AVP 0\r\n",
195 "{attributes}",
196 ),
197 port = port,
198 attributes = attributes,
199 );
200 sipx_sdp::parse(&text).expect("the fixture parses")
201 }
202
203 fn read(session: &SessionDescription) -> Negotiation {
204 negotiate(session, session.media.first().expect("one stream"))
205 }
206
207 const CREDENTIALS: &str = "a=ice-ufrag:8hhY\r\na=ice-pwd:asd88fgpdd777uzjYhagZg\r\n";
208
209 /// [spec] §13.3 and the vision's hard line: no `a=candidate` is not an error, it is the
210 /// common case, and it must produce no ICE at all rather than a degraded ICE.
211 ///
212 /// [spec]: https://github.com/codewandler/sipx/blob/main/docs/specs/ice.md
213 #[test]
214 fn a_peer_that_offers_no_candidates_is_not_doing_ice() {
215 assert_eq!(read(&offer(49170, "")), Negotiation::Absent);
216 // Nor is one that sent credentials and nothing to check.
217 assert_eq!(read(&offer(49170, CREDENTIALS)), Negotiation::Absent);
218 }
219
220 /// The ordinary case: the default destination is one of the candidates.
221 #[test]
222 fn a_default_destination_that_is_a_candidate_runs_ice() {
223 let session = offer(
224 49170,
225 &format!("{CREDENTIALS}a=candidate:1 1 UDP 2130706431 192.0.2.1 49170 typ host\r\n"),
226 );
227 let Negotiation::Ice {
228 credentials,
229 candidates,
230 lite,
231 } = read(&session)
232 else {
233 panic!("the default destination is the candidate");
234 };
235 assert_eq!(credentials.ufrag(), "8hhY");
236 assert_eq!(candidates.len(), 1);
237 assert!(!lite);
238 }
239
240 /// RFC 8839 §5.3, and the reason the attribute exists: something between the two agents
241 /// rewrote the address the offerer advertised, so the candidates describe one path and the
242 /// `c=`/`m=` pair another.
243 #[test]
244 fn a_default_destination_no_candidate_matches_is_an_ice_mismatch() {
245 let session = offer(
246 49170,
247 &format!("{CREDENTIALS}a=candidate:1 1 UDP 2130706431 192.0.2.9 8998 typ host\r\n"),
248 );
249 assert_eq!(read(&session), Negotiation::Mismatch);
250 assert_eq!(
251 read(&session).answer_attributes(),
252 vec![sipx_sdp::Attribute::flag("ice-mismatch")]
253 );
254 assert!(!read(&session).runs_ice());
255 }
256
257 /// The port has to match as well as the address — an ALG that rewrites only the port produces
258 /// exactly this, and it is the case a comparison on the IP alone lets through.
259 #[test]
260 fn the_default_destination_matches_on_the_port_too() {
261 let session = offer(
262 49170,
263 &format!("{CREDENTIALS}a=candidate:1 1 UDP 2130706431 192.0.2.1 8998 typ host\r\n"),
264 );
265 assert_eq!(read(&session), Negotiation::Mismatch);
266 }
267
268 /// A peer offering an RTCP component has an RTCP default destination too (RFC 3550 §11), and
269 /// a peer offering RTP alone does not — §6.1.2.2 reduces the stream to the components both
270 /// agents have, so there is nothing there to mismatch.
271 #[test]
272 fn the_rtcp_default_is_only_checked_when_the_peer_offered_that_component() {
273 let rtp_only = offer(
274 49170,
275 &format!("{CREDENTIALS}a=candidate:1 1 UDP 2130706431 192.0.2.1 49170 typ host\r\n"),
276 );
277 assert!(read(&rtp_only).runs_ice());
278
279 let both = offer(
280 49170,
281 &format!(
282 "{CREDENTIALS}\
283 a=candidate:1 1 UDP 2130706431 192.0.2.1 49170 typ host\r\n\
284 a=candidate:1 2 UDP 2130706430 192.0.2.1 49171 typ host\r\n"
285 ),
286 );
287 assert!(read(&both).runs_ice());
288
289 // The same pair of components, with the RTCP candidate on a port that is not the
290 // convention's. That is §5.3 for component 2.
291 let moved = offer(
292 49170,
293 &format!(
294 "{CREDENTIALS}\
295 a=candidate:1 1 UDP 2130706431 192.0.2.1 49170 typ host\r\n\
296 a=candidate:1 2 UDP 2130706430 192.0.2.1 60000 typ host\r\n"
297 ),
298 );
299 assert_eq!(read(&moved), Negotiation::Mismatch);
300 }
301
302 /// Candidates and no credentials cannot run ICE — but they are not §5.3's diagnosis either,
303 /// because nothing rewrote the default destination.
304 #[test]
305 fn candidates_without_credentials_fall_back_rather_than_report_a_mismatch() {
306 let session = offer(
307 49170,
308 "a=candidate:1 1 UDP 2130706431 192.0.2.1 49170 typ host\r\n",
309 );
310 assert_eq!(read(&session), Negotiation::Absent);
311 }
312
313 /// A rejected stream (RFC 3264 §6) has no destination, so it has no default destination to
314 /// mismatch — and a `c=` naming a name rather than a literal has none either.
315 #[test]
316 fn a_stream_with_no_destination_is_not_a_mismatch() {
317 let rejected = offer(
318 0,
319 &format!("{CREDENTIALS}a=candidate:1 1 UDP 2130706431 192.0.2.1 49170 typ host\r\n"),
320 );
321 assert!(read(&rejected).runs_ice());
322 }
323
324 /// §5.3 puts `a=ice-lite` at session level, and a full agent facing a lite peer controls
325 /// unconditionally (RFC 8445 §6.1.1) — so the flag has to survive the read.
326 #[test]
327 fn a_lite_peer_is_reported_as_one() {
328 let text = concat!(
329 "v=0\r\n",
330 "o=- 1 1 IN IP4 192.0.2.1\r\n",
331 "s=-\r\n",
332 "c=IN IP4 192.0.2.1\r\n",
333 "t=0 0\r\n",
334 "a=ice-lite\r\n",
335 "a=ice-ufrag:8hhY\r\n",
336 "a=ice-pwd:asd88fgpdd777uzjYhagZg\r\n",
337 "m=audio 49170 RTP/AVP 0\r\n",
338 "a=candidate:1 1 UDP 2130706431 192.0.2.1 49170 typ host\r\n",
339 );
340 let session = sipx_sdp::parse(text).expect("parses");
341 let Negotiation::Ice { lite, .. } = read(&session) else {
342 panic!("ice is on");
343 };
344 assert!(lite, "a=ice-lite is session level");
345 }
346}