Skip to content

Resolve outbound destinations with DNS SRV - #809

Open
jaylim95 wants to merge 1 commit into
livekit:mainfrom
jaylim95:dns-srv-outbound
Open

Resolve outbound destinations with DNS SRV#809
jaylim95 wants to merge 1 commit into
livekit:mainfrom
jaylim95:dns-srv-outbound

Conversation

@jaylim95

@jaylim95 jaylim95 commented Aug 25, 2026

Copy link
Copy Markdown

Problem

An outbound trunk address that is a bare host name never goes through DNS SRV. A trunk configured with address: sip.example.com always lands on that name's A record at port 5060, even when the carrier publishes _sip._udp.sip.example.com.

livekit/sip doesn't resolve the trunk address itself: the host name reaches sipgo as the request URI, and resolveAddr in transport/layer.go (github.com/livekit/sipgo v0.13.2-0.20260519205735-a5b4a38b6ceb) resolves it like this:

ip, err := net.ResolveIPAddr("ip", host)
if err == nil {
    addr.IP = ip.IP
    return nil                                       // (1)
}
_, addrs, err := l.dnsResolver.LookupSRV(ctx, "sip", lookupnet, host)
...
a := addrs[0]
addr.IP = net.ParseIP(a.Target[:len(a.Target)-1])    // (2)
addr.Port = int(a.Port)
  1. The address lookup is tried first, so SRV is only reached for a host that has no A/AAAA record at all. RFC 3263 section 4 has it the other way round: SRV first, address records as the last resort.
  2. The SRV branch does not work even when it is reached, because net.ParseIP is given the SRV target, which is a host name. It returns nil, and the connection is built against net.UDPAddr{IP: nil, Port: <srv port>}. The target needs a second address lookup that never happens.

Quick check against a domain that publishes SIP SRV records:

ResolveIPAddr("example.com")  = 172.66.147.243, err=<nil>       // SRV never consulted
LookupSRV("sip","udp","linphone.org") → target="sip.linphone.org." port=5060
    net.ParseIP("sip.linphone.org") = <nil>                     // no second lookup

resolveAddr also maps the TLS transport onto _sip._tcp rather than _sips._tcp.

What this does

Resolves the next hop in sipOutbound and pins it on the INVITE with SetDestination, so the transport layer sees an address literal and doesn't resolve the name again.

  • pkg/sip/dns.goresolveNextHop implements the RFC 3263 section 4 lookup: SRV for the request's transport (_sip._udp, _sip._tcp, _sips._tcp, plus the RFC 7118 WebSocket labels), then an address lookup of the chosen target. LookupSRV already orders records by priority and shuffles by weight, so the first target that resolves wins; targets that don't resolve are skipped.
  • pkg/sip/outbound.goattemptInvite pins the resolved destination. The next hop is the first Route header if there is one and the request URI otherwise, matching how the transport layer picks it.
  • pkg/config/config.godisable_dns_srv to opt out.

Compatibility

The lookup order only changes for a host name that has no explicit port, which is the case RFC 3263 reserves for SRV:

Trunk address Before After
1.2.3.4 / 1.2.3.4:5080 unchanged unchanged, no DNS at all
sip.example.com:5080 A record, port 5080 unchanged — an explicit port means the hop is already chosen (RFC 3263 section 4.2), SRV is not consulted
sip.example.com, no SRV published A record, port 5060 unchanged
sip.example.com, SRV published A record, port 5060 SRV target and port

Resolution failures are not fatal: the destination is left unset and the transport layer falls back to its own lookup, so an unresolvable host still fails the way it does today.

Two things fall out of pinning the destination:

  • The ACK, CANCEL and BYE built from the INVITE inherit its destination, so they follow the INVITE to the same host instead of being resolved again and possibly landing on a different address record. applyInviteResponse still flushes the destination when the answering Contact or the route set moves the dialog somewhere else.
  • The result is cached per call, so an INVITE retried after a 401/407 goes back to the same SRV target. Otherwise the weight shuffle could pick a different one, where the nonce from the challenge isn't valid.

Not included

  • NAPTR (RFC 3263 section 4.1). The transport is always already known here, from the trunk config or the URI's transport parameter, so there is nothing for NAPTR to select.
  • Failover to the next SRV target after the transport is up. A target that doesn't resolve is skipped, but a target that resolves and then doesn't answer is not retried — that needs transaction-level retries.
  • In-dialog requests. Once applyInviteResponse repoints the dialog at the answering Contact, later requests are resolved by the transport layer as before.

The underlying resolveAddr bug is in sipgo, and fixing it there would cover inbound and in-dialog paths too. I put the fix here because it's contained and doesn't need a dependency bump, but happy to send it to livekit/sipgo instead, or as well, if you'd rather have it at the root.

Also happy to flip disable_dns_srv into an opt-in flag if you'd prefer the new lookup order behind a switch to start with.

Testing

  • pkg/sip/dns_test.go — table tests over a stubbed resolver: SRV per transport, explicit port skipping SRV, unresolvable targets being skipped, an RFC 2782 . target, address-record fallback, IPv6, IP literals.
  • pkg/sip/outbound_test.goTestOutboundINVITEUsesSRVDestination asserts the INVITE goes to the SRV target while the request URI keeps the host name, and that the ACK follows it there. TestOutboundINVITERouteHeaderIsTheNextHop covers outbound_route_headers: the proxy in the Route header is what gets resolved, not the request URI. TestOutboundINVITEDNSSRVDisabled covers the config flag. Without the change, the first fails with expected: 192.0.2.10:5080, actual: sip.example.com:5060.
  • NewOutboundTestClient now injects a resolver that resolves nothing by default, so the existing tests don't touch the network.
  • go test ./pkg/... and golangci-lint run (v2.13.1, matching CI) are clean locally. Note that TestTransfer and TestRouteSet can fail their monitor should be healthy assertion when the full suite runs on a loaded machine — that reproduces on unmodified main and is unrelated to this change.

@jaylim95
jaylim95 requested a review from a team as a code owner August 25, 2026 04:30
devin-ai-integration[bot]

This comment was marked as resolved.

An outbound trunk address like "sip.example.com" is handed to the sipgo
transport layer as a name, and resolveAddr() there tries net.ResolveIPAddr
first and only falls back to SRV when the host has no address record. That
is the reverse of RFC 3263 section 4, so in practice SRV records published
for a trunk are never used. The fallback would not work either: it runs
net.ParseIP over the SRV target, which is a host name, so the resolved IP
comes out nil.

Resolve the next hop here instead and pin it on the INVITE, which also
keeps the transport layer from resolving the name a second time. SRV is
only consulted when the URI carries no explicit port, per RFC 3263 section
4.2, so an address with a port or an IP literal keeps behaving exactly as
before, and a name that publishes no usable SRV record still falls back to
an address lookup at the transport's default port.

Pinning the destination also means the ACK, CANCEL and BYE built from the
INVITE reach the same host it did, rather than being resolved again and
possibly landing on a different address record.

Set disable_dns_srv to opt out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.54839% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 67.34%. Comparing base (0460b40) to head (dad2227).
⚠️ Report is 354 commits behind head on main.

Files with missing lines Patch % Lines
pkg/sip/dns.go 90.00% 2 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #809      +/-   ##
==========================================
+ Coverage   65.25%   67.34%   +2.08%     
==========================================
  Files          51       43       -8     
  Lines        6588     8258    +1670     
==========================================
+ Hits         4299     5561    +1262     
- Misses       1915     2207     +292     
- Partials      374      490     +116     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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