Skip to main content

sipx_sdp/
browser_audio.rs

1//! Pure SDP policy for the named browser-compatible audio profile.
2//!
3//! The profile is deliberately narrow: one audio stream, one ICE component, multiplexed RTCP,
4//! DTLS-SRTP, and a fixed required codec vocabulary. Validation is pure and fail-closed; socket
5//! ownership and protocol state remain in `sipx-media`.
6
7use std::net::IpAddr;
8
9use crate::answer::{fingerprint_of, negotiate_direction, setup_of};
10use crate::fingerprint::{Fingerprint, HashFunc, Setup, SetupCapabilities};
11use crate::ice::{Candidate, CandidateType, ComponentId, Credentials, ICE2};
12use crate::{Attribute, Connection, Direction, MediaDescription, SessionDescription};
13
14const PROTOCOL: &str = "UDP/TLS/RTP/SAVPF";
15const LOCAL_FORMATS: [&str; 5] = ["111", "0", "8", "13", "101"];
16const MAX_CANDIDATES: usize = 32;
17const MAX_CANDIDATE_LINE: usize = 512;
18
19/// Which side authored a description.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum BrowserAudioRole {
22    /// The description is an offer.
23    Offerer,
24    /// The description is an answer.
25    Answerer,
26}
27
28/// Payload numbers carrying the required audio vocabulary.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub struct BrowserAudioPayloads {
31    /// Opus at 48 kHz and two RTP channels.
32    pub opus: u8,
33    /// G.711 mu-law.
34    pub pcmu: u8,
35    /// G.711 A-law.
36    pub pcma: u8,
37    /// Comfort noise at 8 kHz.
38    pub comfort_noise: u8,
39    /// RFC 4733 telephone events at 8 kHz.
40    pub telephone_event: u8,
41}
42
43/// A description that crossed every browser-audio SDP boundary.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct BrowserAudioDescription {
46    /// Whether the peer authored an offer or answer.
47    pub role: BrowserAudioRole,
48    /// Required payload mappings.
49    pub payloads: BrowserAudioPayloads,
50    /// First usable audio payload in preference order.
51    pub selected_audio_payload: u8,
52    /// Declared direction.
53    pub direction: Direction,
54    /// Current peer ICE credentials.
55    pub ice: Credentials,
56    /// Usable component-one candidates in wire order.
57    pub candidates: Vec<Candidate>,
58    /// Peer certificate fingerprint from signalling.
59    pub fingerprint: Fingerprint,
60    /// Peer's resolved DTLS setup declaration.
61    pub setup: Setup,
62    /// Default media address, retained as a fact and never as nomination.
63    pub address: IpAddr,
64    /// Default media port, retained as a fact and never as nomination.
65    pub port: u16,
66}
67
68/// A validated answer and the complementary local DTLS role.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct BrowserAudioAnswer {
71    /// Validated remote answer facts.
72    pub description: BrowserAudioDescription,
73    /// Offerer's local DTLS role.
74    pub local_setup: Setup,
75}
76
77/// Whether a subsequent description starts another ICE generation.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum IceChange {
80    /// Both credentials are unchanged.
81    Unchanged,
82    /// Both credentials changed together.
83    Restart,
84}
85
86/// A validated subsequent description.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct BrowserAudioRenegotiation {
89    /// New description facts.
90    pub description: BrowserAudioDescription,
91    /// Relationship to the current ICE generation.
92    pub ice_change: IceChange,
93}
94
95/// Local facts required to emit a complete description.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct BrowserAudioLocal {
98    /// Default candidate address.
99    pub address: IpAddr,
100    /// Bound component-one port.
101    pub port: u16,
102    /// Stable SDP session identifier.
103    pub session_id: u64,
104    /// Increasing SDP session version.
105    pub session_version: u64,
106    /// Requested direction.
107    pub direction: Direction,
108    /// Fresh credentials for this ICE generation.
109    pub ice: Credentials,
110    /// Gathered component-one candidates.
111    pub candidates: Vec<Candidate>,
112    /// Local certificate's SHA-256 fingerprint.
113    pub fingerprint: Fingerprint,
114    /// DTLS roles available locally.
115    pub setup: SetupCapabilities,
116}
117
118/// A fail-closed profile boundary.
119#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
120#[non_exhaustive]
121pub enum ProfileError {
122    /// Required Opus capability is unavailable.
123    #[error("Opus is required by the browser-audio profile")]
124    OpusUnavailable,
125    /// Signalling is not authenticated SIP over WSS.
126    #[error("the browser-audio profile requires authenticated SIP over WSS")]
127    InsecureSignalling,
128    /// The description does not contain exactly one active audio section.
129    #[error("the browser-audio profile requires exactly one active audio section")]
130    MediaSectionCount,
131    /// An initial offer names an unsupported protocol.
132    #[error("the media protocol is not UDP/TLS/RTP/SAVPF")]
133    WrongProtocol,
134    /// Multiplexed RTCP or its single component is absent.
135    #[error("the browser-audio profile requires multiplexed RTCP on component one")]
136    RtcpMuxRequired,
137    /// ICE credentials or candidates are absent or unusable.
138    #[error("the browser-audio profile requires a complete usable ICE generation")]
139    IceRequired,
140    /// DTLS setup is absent, unresolved, or locally unavailable.
141    #[error("the DTLS setup role is incompatible")]
142    SetupRole,
143    /// No usable SHA-256 fingerprint is present.
144    #[error("a SHA-256 certificate fingerprint is required")]
145    FingerprintRequired,
146    /// The mandatory codec vocabulary is absent or ambiguous.
147    #[error("the required browser-audio codec set is incomplete")]
148    CodecSetIncomplete,
149    /// A description attempts a weaker media mode.
150    #[error("the description attempts to weaken the selected media profile")]
151    WeakerMedia,
152    /// A subsequent description removed a mandatory profile fact.
153    #[error("a subsequent description removed the browser-audio profile")]
154    ProfileRemoved,
155    /// ICE ended before nomination.
156    #[error("ICE produced no nominated component-one pair")]
157    NoNominatedPair,
158    /// The DTLS handshake expired.
159    #[error("the DTLS handshake timed out")]
160    DtlsTimeout,
161    /// The media certificate differs from signalling.
162    #[error("the DTLS certificate fingerprint did not match signalling")]
163    FingerprintMismatch,
164    /// DTLS selected no supported SRTP profile.
165    #[error("DTLS negotiated no supported SRTP profile")]
166    NoSrtpProfile,
167    /// Cancellation won and cleanup completed.
168    #[error("browser-audio setup was cancelled")]
169    Cancelled,
170}
171
172/// Emit a complete initial offer.
173pub fn offer(local: &BrowserAudioLocal) -> Result<SessionDescription, ProfileError> {
174    validate_local(local)?;
175    build(
176        local,
177        Setup::ActPass,
178        &LOCAL_FORMATS,
179        BrowserAudioPayloads {
180            opus: 111,
181            pcmu: 0,
182            pcma: 8,
183            comfort_noise: 13,
184            telephone_event: 101,
185        },
186        local.direction,
187    )
188}
189
190/// Emit an answer preserving the offered payload numbers and order.
191pub fn answer(
192    offered: &SessionDescription,
193    local: &BrowserAudioLocal,
194) -> Result<SessionDescription, ProfileError> {
195    let remote = validate(offered, BrowserAudioRole::Offerer)?;
196    validate_local(local)?;
197    let setup = local
198        .setup
199        .answer_to(remote.setup)
200        .map_err(|_| ProfileError::SetupRole)?;
201    let required = [
202        remote.payloads.opus,
203        remote.payloads.pcmu,
204        remote.payloads.pcma,
205        remote.payloads.comfort_noise,
206        remote.payloads.telephone_event,
207    ];
208    let offered_media = offered
209        .media
210        .first()
211        .ok_or(ProfileError::MediaSectionCount)?;
212    let formats: Vec<&str> = offered_media
213        .formats
214        .iter()
215        .filter(|format| {
216            format
217                .parse::<u8>()
218                .is_ok_and(|payload| required.contains(&payload))
219        })
220        .map(String::as_str)
221        .collect();
222    build(
223        local,
224        setup,
225        &formats,
226        remote.payloads,
227        negotiate_direction(remote.direction, local.direction),
228    )
229}
230
231/// Validate an initial offer or answer without I/O.
232pub fn validate(
233    description: &SessionDescription,
234    role: BrowserAudioRole,
235) -> Result<BrowserAudioDescription, ProfileError> {
236    let media = match description.media.as_slice() {
237        [media] if media.media == "audio" && !media.is_rejected() => media,
238        _ => return Err(ProfileError::MediaSectionCount),
239    };
240    if has_attribute(description, media, "crypto") {
241        return Err(ProfileError::WeakerMedia);
242    }
243    if media.protocol != PROTOCOL {
244        return match role {
245            BrowserAudioRole::Answerer => Err(ProfileError::WeakerMedia),
246            BrowserAudioRole::Offerer if is_weaker_protocol(&media.protocol) => {
247                Err(ProfileError::WeakerMedia)
248            }
249            BrowserAudioRole::Offerer => Err(ProfileError::WrongProtocol),
250        };
251    }
252    if !media.rtcp_mux()
253        || media
254            .attribute("rtcp")
255            .is_some_and(|attribute| !is_mux_placeholder(attribute.value.as_deref()))
256        || raw_candidates(media)
257            .filter_map(raw_candidate_component)
258            .any(|component| component != ComponentId::RTP)
259    {
260        return Err(ProfileError::RtcpMuxRequired);
261    }
262    if media.ice_mismatch()
263        || !description
264            .ice_options_for(media)
265            .any(|option| matches!(option, ICE2 | "trickle"))
266    {
267        return Err(ProfileError::IceRequired);
268    }
269    let ice = description
270        .ice_credentials_for(media)
271        .ok_or(ProfileError::IceRequired)?;
272    let raw_candidates: Vec<&str> = raw_candidates(media).collect();
273    if raw_candidates.is_empty()
274        || raw_candidates.len() > MAX_CANDIDATES
275        || raw_candidates
276            .iter()
277            .any(|value| value.len() > MAX_CANDIDATE_LINE)
278    {
279        return Err(ProfileError::IceRequired);
280    }
281    let candidates: Vec<Candidate> = raw_candidates
282        .into_iter()
283        .map(Candidate::parse)
284        .collect::<Option<_>>()
285        .ok_or(ProfileError::IceRequired)?;
286    if candidates.iter().any(|candidate| {
287        candidate.component != ComponentId::RTP
288            || !matches!(
289                candidate.kind,
290                CandidateType::Host | CandidateType::ServerReflexive
291            )
292    }) {
293        return Err(ProfileError::IceRequired);
294    }
295    let setup = setup_of(description, media).ok_or(ProfileError::SetupRole)?;
296    let setup_ok = match role {
297        BrowserAudioRole::Offerer => setup == Setup::ActPass,
298        BrowserAudioRole::Answerer => matches!(setup, Setup::Active | Setup::Passive),
299    };
300    if !setup_ok {
301        return Err(ProfileError::SetupRole);
302    }
303    let fingerprint = fingerprint_of(description, media)
304        .filter(|value| value.func == HashFunc::Sha256)
305        .ok_or(ProfileError::FingerprintRequired)?;
306    let payloads = payloads(media)?;
307    let address = description
308        .address_for(media)
309        .filter(|value| !value.is_unspecified())
310        .ok_or(ProfileError::IceRequired)?;
311    let selected_audio_payload = media
312        .formats
313        .iter()
314        .filter_map(|format| format.parse::<u8>().ok())
315        .find(|payload| [payloads.opus, payloads.pcmu, payloads.pcma].contains(payload))
316        .ok_or(ProfileError::CodecSetIncomplete)?;
317    Ok(BrowserAudioDescription {
318        role,
319        payloads,
320        selected_audio_payload,
321        direction: media
322            .declared_direction()
323            .unwrap_or_else(|| description.direction()),
324        ice,
325        candidates,
326        fingerprint,
327        setup,
328        address,
329        port: media.port,
330    })
331}
332
333/// Validate a complete exchange and resolve the offerer's local DTLS role.
334pub fn validate_answer(
335    offered: &SessionDescription,
336    answered: &SessionDescription,
337    local_setup: SetupCapabilities,
338) -> Result<BrowserAudioAnswer, ProfileError> {
339    let offered_profile = validate(offered, BrowserAudioRole::Offerer)?;
340    let description = validate(answered, BrowserAudioRole::Answerer)?;
341    let required = [
342        offered_profile.payloads.opus,
343        offered_profile.payloads.pcmu,
344        offered_profile.payloads.pcma,
345        offered_profile.payloads.comfort_noise,
346        offered_profile.payloads.telephone_event,
347    ];
348    let offered_media = offered
349        .media
350        .first()
351        .ok_or(ProfileError::MediaSectionCount)?;
352    let answered_media = answered
353        .media
354        .first()
355        .ok_or(ProfileError::MediaSectionCount)?;
356    let expected: Vec<&str> = offered_media
357        .formats
358        .iter()
359        .filter(|format| {
360            format
361                .parse::<u8>()
362                .is_ok_and(|payload| required.contains(&payload))
363        })
364        .map(String::as_str)
365        .collect();
366    if expected != answered_media.formats || offered_profile.payloads != description.payloads {
367        return Err(ProfileError::CodecSetIncomplete);
368    }
369    let local_setup = local_setup
370        .from_answer(Some(description.setup))
371        .map_err(|_| ProfileError::SetupRole)?;
372    Ok(BrowserAudioAnswer {
373        description,
374        local_setup,
375    })
376}
377
378/// Validate a subsequent description without mutating the current generation.
379pub fn validate_reoffer(
380    current: &SessionDescription,
381    next: &SessionDescription,
382    role: BrowserAudioRole,
383) -> Result<BrowserAudioRenegotiation, ProfileError> {
384    let current = validate(current, role)?;
385    let description = validate(next, role).map_err(|error| match error {
386        ProfileError::IceRequired => ProfileError::IceRequired,
387        ProfileError::MediaSectionCount
388        | ProfileError::WrongProtocol
389        | ProfileError::RtcpMuxRequired
390        | ProfileError::SetupRole
391        | ProfileError::FingerprintRequired
392        | ProfileError::CodecSetIncomplete
393        | ProfileError::WeakerMedia => ProfileError::ProfileRemoved,
394        other => other,
395    })?;
396    let ufrag_changed = current.ice.ufrag() != description.ice.ufrag();
397    let pwd_changed = current.ice.pwd() != description.ice.pwd();
398    let ice_change = match (ufrag_changed, pwd_changed) {
399        (false, false) => IceChange::Unchanged,
400        (true, true) => IceChange::Restart,
401        _ => return Err(ProfileError::IceRequired),
402    };
403    if current.payloads != description.payloads
404        || ice_change == IceChange::Unchanged
405            && (current.fingerprint != description.fingerprint
406                || current.setup != description.setup)
407    {
408        return Err(ProfileError::ProfileRemoved);
409    }
410    Ok(BrowserAudioRenegotiation {
411        description,
412        ice_change,
413    })
414}
415
416fn validate_local(local: &BrowserAudioLocal) -> Result<(), ProfileError> {
417    if local.port == 0 || local.address.is_unspecified() {
418        return Err(ProfileError::IceRequired);
419    }
420    if local.fingerprint.func != HashFunc::Sha256 {
421        return Err(ProfileError::FingerprintRequired);
422    }
423    if local.candidates.is_empty()
424        || local.candidates.len() > MAX_CANDIDATES
425        || local.candidates.iter().any(|candidate| {
426            candidate.component != ComponentId::RTP
427                || !matches!(
428                    candidate.kind,
429                    CandidateType::Host | CandidateType::ServerReflexive
430                )
431                || candidate.to_value().len() > MAX_CANDIDATE_LINE
432        })
433    {
434        return Err(ProfileError::IceRequired);
435    }
436    local
437        .setup
438        .answer_to(Setup::ActPass)
439        .map_err(|_| ProfileError::SetupRole)?;
440    Ok(())
441}
442
443fn build(
444    local: &BrowserAudioLocal,
445    setup: Setup,
446    formats: &[&str],
447    payloads: BrowserAudioPayloads,
448    direction: Direction,
449) -> Result<SessionDescription, ProfileError> {
450    let mut description =
451        SessionDescription::new(local.address, local.session_id, local.session_version);
452    // The single stream owns its default; the normative profile carries no session `c=` line.
453    description.connection = None;
454    description
455        .attributes
456        .push(Attribute::valued("ice-options", ICE2));
457    let mut media = MediaDescription {
458        media: "audio".to_owned(),
459        port: local.port,
460        protocol: PROTOCOL.to_owned(),
461        formats: formats.iter().map(|value| (*value).to_owned()).collect(),
462        connection: Some(Connection::new(local.address)),
463        attributes: vec![
464            Attribute::flag(direction.as_str()),
465            Attribute::flag("rtcp-mux"),
466            Attribute::valued("ice-ufrag", local.ice.ufrag()),
467            Attribute::valued("ice-pwd", local.ice.pwd()),
468        ],
469        other: Vec::new(),
470    };
471    media.attributes.extend(
472        local
473            .candidates
474            .iter()
475            .map(|candidate| Attribute::valued("candidate", candidate.to_value())),
476    );
477    media.attributes.extend([
478        Attribute::valued("fingerprint", local.fingerprint.to_value()),
479        Attribute::valued("setup", setup.as_str()),
480        Attribute::valued("rtpmap", format!("{} opus/48000/2", payloads.opus)),
481        Attribute::valued("rtpmap", format!("{} PCMU/8000", payloads.pcmu)),
482        Attribute::valued("rtpmap", format!("{} PCMA/8000", payloads.pcma)),
483        Attribute::valued("rtpmap", format!("{} CN/8000", payloads.comfort_noise)),
484        Attribute::valued(
485            "rtpmap",
486            format!("{} telephone-event/8000", payloads.telephone_event),
487        ),
488        Attribute::valued("fmtp", format!("{} 0-16", payloads.telephone_event)),
489    ]);
490    description.media.push(media);
491    let role = if setup == Setup::ActPass {
492        BrowserAudioRole::Offerer
493    } else {
494        BrowserAudioRole::Answerer
495    };
496    validate(&description, role)?;
497    Ok(description)
498}
499
500fn payloads(media: &MediaDescription) -> Result<BrowserAudioPayloads, ProfileError> {
501    if media.formats.len() < 5 {
502        return Err(ProfileError::CodecSetIncomplete);
503    }
504    let parsed: Vec<u8> = media
505        .formats
506        .iter()
507        .map(|format| format.parse::<u8>())
508        .collect::<Result<_, _>>()
509        .map_err(|_| ProfileError::CodecSetIncomplete)?;
510    let mut unique_formats = parsed.clone();
511    unique_formats.sort_unstable();
512    unique_formats.dedup();
513    if unique_formats.len() != parsed.len()
514        || parsed.iter().any(|payload| (64..=95).contains(payload))
515    {
516        return Err(ProfileError::CodecSetIncomplete);
517    }
518    let opus = find_mapping(media, "opus", 48_000, Some(2))?;
519    let mu_law = static_or_mapping(media, 0, "PCMU", 8_000)?;
520    let a_law = static_or_mapping(media, 8, "PCMA", 8_000)?;
521    let comfort_noise = exact_mapping(media, 13, "CN", 8_000, None)?;
522    let telephone_event = find_mapping(media, "telephone-event", 8_000, None)?;
523    if !telephone_events_cover_dtmf(media, telephone_event) {
524        return Err(ProfileError::CodecSetIncomplete);
525    }
526    let required = [opus, mu_law, a_law, comfort_noise, telephone_event];
527    if required.iter().any(|payload| !parsed.contains(payload)) {
528        return Err(ProfileError::CodecSetIncomplete);
529    }
530    let mut unique = required.to_vec();
531    unique.sort_unstable();
532    unique.dedup();
533    if unique.len() != 5 {
534        return Err(ProfileError::CodecSetIncomplete);
535    }
536    Ok(BrowserAudioPayloads {
537        opus,
538        pcmu: mu_law,
539        pcma: a_law,
540        comfort_noise,
541        telephone_event,
542    })
543}
544
545fn find_mapping(
546    media: &MediaDescription,
547    encoding: &str,
548    clock: u32,
549    channels: Option<u8>,
550) -> Result<u8, ProfileError> {
551    let matches: Vec<u8> = media
552        .formats
553        .iter()
554        .filter_map(|format| {
555            let mapping = media.rtpmap(format)?;
556            mapping_matches(mapping, encoding, clock, channels)
557                .then(|| format.parse().ok())
558                .flatten()
559        })
560        .collect();
561    match matches.as_slice() {
562        [payload] => Ok(*payload),
563        _ => Err(ProfileError::CodecSetIncomplete),
564    }
565}
566
567fn static_or_mapping(
568    media: &MediaDescription,
569    payload: u8,
570    encoding: &str,
571    clock: u32,
572) -> Result<u8, ProfileError> {
573    let format = payload.to_string();
574    if !media.formats.contains(&format) {
575        return Err(ProfileError::CodecSetIncomplete);
576    }
577    match media.rtpmap(&format) {
578        None => Ok(payload),
579        Some(mapping) if mapping_matches(mapping, encoding, clock, None) => Ok(payload),
580        Some(_) => Err(ProfileError::CodecSetIncomplete),
581    }
582}
583
584fn exact_mapping(
585    media: &MediaDescription,
586    payload: u8,
587    encoding: &str,
588    clock: u32,
589    channels: Option<u8>,
590) -> Result<u8, ProfileError> {
591    let format = payload.to_string();
592    match media.rtpmap(&format) {
593        Some(mapping)
594            if media.formats.contains(&format)
595                && mapping_matches(mapping, encoding, clock, channels) =>
596        {
597            Ok(payload)
598        }
599        _ => Err(ProfileError::CodecSetIncomplete),
600    }
601}
602
603fn mapping_matches(mapping: &str, encoding: &str, clock: u32, channels: Option<u8>) -> bool {
604    let mut parts = mapping.split('/');
605    let matches = parts
606        .next()
607        .is_some_and(|actual| actual.eq_ignore_ascii_case(encoding))
608        && parts.next().and_then(|actual| actual.parse::<u32>().ok()) == Some(clock);
609    let actual_channels = parts.next().and_then(|actual| actual.parse::<u8>().ok());
610    matches && actual_channels == channels && parts.next().is_none()
611}
612
613fn telephone_events_cover_dtmf(media: &MediaDescription, payload: u8) -> bool {
614    let format = payload.to_string();
615    let parameters = media.attributes.iter().find_map(|attribute| {
616        if attribute.name != "fmtp" {
617            return None;
618        }
619        let value = attribute.value.as_deref()?;
620        let (actual, parameters) = value.split_once(' ')?;
621        (actual == format).then_some(parameters)
622    });
623    let Some(parameters) = parameters else {
624        // RFC 4733 ยง2.5.1.1 defines absent events as the telephone-event 0-15 default.
625        return true;
626    };
627    (0_u8..=15).all(|wanted| {
628        parameters.split(',').any(|part| {
629            let (start, end) = part
630                .split_once('-')
631                .map_or((part, part), |(start, end)| (start, end));
632            let Some(start) = start.parse::<u8>().ok() else {
633                return false;
634            };
635            let Some(end) = end.parse::<u8>().ok() else {
636                return false;
637            };
638            (start..=end).contains(&wanted)
639        })
640    })
641}
642
643fn has_attribute(description: &SessionDescription, media: &MediaDescription, name: &str) -> bool {
644    description
645        .attributes
646        .iter()
647        .chain(media.attributes.iter())
648        .any(|attribute| attribute.name == name)
649}
650
651fn raw_candidates(media: &MediaDescription) -> impl Iterator<Item = &str> {
652    media
653        .attributes
654        .iter()
655        .filter(|attribute| attribute.name == "candidate")
656        .filter_map(|attribute| attribute.value.as_deref())
657}
658
659fn raw_candidate_component(value: &str) -> Option<ComponentId> {
660    let component = value.split_whitespace().nth(1)?.parse::<u16>().ok()?;
661    ComponentId::new(component)
662}
663
664fn is_weaker_protocol(protocol: &str) -> bool {
665    matches!(protocol, "RTP/AVP" | "RTP/SAVP" | "UDP/TLS/RTP/SAVP")
666}
667
668fn is_mux_placeholder(value: Option<&str>) -> bool {
669    matches!(
670        value,
671        Some("9 IN IP4 0.0.0.0" | "9 IN IP6 ::" | "9 IN IP6 0:0:0:0:0:0:0:0")
672    )
673}