Skip to main content

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}