Skip to content

feat(sip): M6 CP3 — external_media_address FQDN-vs-literal in SDP, fail-closed on DNS failure - #151

Merged
ryanmurf merged 2 commits into
masterfrom
feat/m6-cp3-sdp-fqdn-fail-closed
Jul 17, 2026
Merged

feat(sip): M6 CP3 — external_media_address FQDN-vs-literal in SDP, fail-closed on DNS failure#151
ryanmurf merged 2 commits into
masterfrom
feat/m6-cp3-sdp-fqdn-fail-closed

Conversation

@ryanmurf

Copy link
Copy Markdown
Owner

M6 CP3 — FQDN-vs-literal in SDP, fail-closed on DNS failure

FINDING. external_media_address was an uninterpreted string emitted verbatim into SDP
c=/o=. A configured FQDN produced a malformed c=IN IP4 myhost.example.com; there was no
resolution and no fail-closed posture.

FIX. Interpret external_media_address for a non-local (external) peer:

  • IP literal (v4/v6) → emitted verbatim.
  • FQDN → resolved to an IP, preferring the peer's address family (an IPv4 peer isn't handed an
    IPv6 media address), else any resolved address.
  • Fail closed on DNS failure: advertised_media_ip now returns Option<String> (None = fail
    closed), and every caller rejects the call setup rather than advertise an unresolved FQDN or fall
    back to a leaky internal address:
    • outbound offer (channel_driver::request) → Err, the dial fails
    • inbound INVITE answer → 488 Not Acceptable Here
    • UPDATE renegotiation answer → 488
    • re-INVITE answer → 488

The resolution is blocking, matching the existing blocking route-selection in the same function;
async resolution + TTL caching is a separate M1/M-l hardening, out of this fail-closed scope.

RECEIVER-SIDE PROOF (e2e_media_fail_closed.rs). A SIP peer sends an INVITE and observes
rustisk's response:

  • positive control — IP-literal external 203.0.113.99 → ANSWERED 200 with c=203.0.113.99.
  • fail-closed — unresolvable FQDN external no-such-host.invalid (RFC 6761 reserved) → the
    INVITE is NOT accepted, rustisk sends a 4xx and NEVER a 200 (no bogus/internal c= reaches the
    peer).

CAPTURED RED. Make the resolution fall open (emit the unresolved FQDN instead of None):

# e2e
assertion failed: fail-closed: an unresolvable external_media_address FQDN must NOT be accepted
  left: Some("media-fc-fail")   right: None
# unit
assertion failed: an unresolvable external_media_address FQDN must fail closed (None)
  left: Some("no-such-host.invalid")   right: None

Plus unit tests: IP-literal passthrough (v4/v6), FQDN resolves (localhost → loopback IP), and
advertised_media_ip_with end-to-end fail-closed (must not fall back to the internal bind).

GATES. cargo test --workspace --exclude pjsip-shim green; cargo clippy --workspace --exclude pjsip-shim -- -D warnings clean. Rust 1.97.0.

Repin Agent added 2 commits July 17, 2026 03:43
…ail closed on DNS failure (M6 CP3)

external_media_address was emitted verbatim into c=/o= — an FQDN produced a
malformed 'c=IN IP4 myhost.example.com'. CP3 interprets it: an IP literal is
emitted as-is; an FQDN is resolved to an IP (preferring the peer's address
family). On DNS failure it FAILS CLOSED — advertised_media_ip now returns
Option<String> and every caller rejects the call setup rather than advertise an
unresolved FQDN or fall back to a leaky internal address:
  - outbound offer (channel_driver::request) -> Err, dial fails
  - inbound INVITE answer -> 488 Not Acceptable Here
  - UPDATE renegotiation answer -> 488
  - re-INVITE answer -> 488

Receiver-side proof (e2e_media_fail_closed.rs): a peer sends an INVITE; an
IP-literal external is ANSWERED 200 with that c= (positive control), an
unresolvable FQDN external is REJECTED with a 4xx and NEVER 200. RED: fall open
(emit the unresolved FQDN) -> the FQDN case is accepted/answered -> assertion
RED. Plus unit tests: IP-literal passthrough, FQDN resolves, unresolvable
fails-closed (None), and end-to-end advertised_media_ip_with fail-closed.
…port selection, bounded DNS

- F1 (MAJOR): resolve the SDP answer BEFORE mutating the media plane in the
  UPDATE and re-INVITE renegotiation paths, so a fail-closed rejection leaves
  the established call's media/hold/next-hop untouched instead of repointing the
  pump and then 488-ing the peer.
- F2 (MAJOR): select the media transport that COVERS local_addr first, then read
  its external_media_address (same fix as CP2 F1 for signaling). Prevents a
  same-ip/wildcard transport's unresolvable external FQDN from rejecting a normal
  call on a transport that has no external address. Unit test added.
- F3 (MAJOR): bound the blocking FQDN resolution with a 3s timeout on a scratch
  thread — a slow NSS/DNS lookup times out to None (fail closed) rather than
  stalling the serialized SIP control path. Full async+TTL DNS remains M1/M-l.
- F4 (test): filter the fail-closed e2e response collection by Call-ID so an
  earlier call's retransmit cannot contaminate the assertion.
@ryanmurf

Copy link
Copy Markdown
Owner Author

Codex review disposition (gpt-5.5, xhigh)

Codex verified all four callers handle None, IPv4/IPv6 literal + family preference, empty-resolution None, initial-INVITE cleanup parity, gating, non-trivial positive control, and the captured RED. Four findings, all FIXED (0b8715b):

  • F1 (MAJOR) — FIXED: the UPDATE and re-INVITE renegotiation paths applied the media-plane mutation (RTP repoint / hold) BEFORE resolving the external address, so a fail-closed 488 left the established call inconsistent. Now the answer is resolved FIRST; on fail-closed the 488 is sent with the call's media/hold/next-hop untouched. Guarded by the existing e2e_reinvite (normal renegotiation still green).
  • F2 (MAJOR) — FIXED: advertised_media_ip filtered for transports that HAVE an external address before matching the bind, so a same-ip/wildcard transport's unresolvable external FQDN could reject a NORMAL call on a no-external transport. Now selects the covering transport first, then reads its config (same fix as CP2 F1 for signaling). Unit test test_media_ip_covering_transport_without_external_not_rejected added.
  • F3 (MAJOR) — FIXED: the blocking to_socket_addrs is now bounded by a 3s timeout on a scratch thread; a slow NSS/DNS lookup times out to None (fail closed) rather than stalling the serialized SIP control path. Full async resolution + TTL caching remains the M1/M-l DNS hardening.
  • F4 (test) — ADDRESSED: the fail-closed e2e now filters response collection by Call-ID so an earlier call's retransmit cannot contaminate the assertion. (The direct-handler harness pattern is the established repo convention; a full-SipStack variant is a broader harness change tracked for later.)

CI green; workspace test + clippy green locally.

@ryanmurf
ryanmurf merged commit ecd339a into master Jul 17, 2026
3 checks passed
@ryanmurf
ryanmurf deleted the feat/m6-cp3-sdp-fqdn-fail-closed branch July 17, 2026 10:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant