Skip to content

Outbound SIP does not follow RFC 3263 SRV: an A record short-circuits the lookup, the SRV port is discarded, and _sips._tcp is never queried #810

Description

@jaylim95

Summary

Outbound trunk resolution does not implement RFC 3263. There are three separate defects
stacked on top of each other, and they interact such that a correctly-provisioned SRV zone is
silently ignored whenever the SIP domain also has an A record — which is the common case.

  1. The destination port is fixed before DNS runs, so an SRV port can never be honoured.
  2. resolveAddr tries A/AAAA first and only falls back to SRV on failure, so any domain
    with an A record never triggers an SRV query at all.
  3. The SRV fallback itself cannot succeed — it calls net.ParseIP() on the SRV target
    hostname
    without resolving it, which always yields nil.

Additionally, the fallback only ever queries _sip._udp / _sip._tcp, so _sips._tcp is
never queried even for a TLS trunk. That last point was reported in #458 (closed as
not-planned), but #458 only captured the service-name facet — the deeper problem is that the
SRV code path is unreachable-then-broken regardless of service name.

Versions

  • livekit/sip — reproduced on v1.7.0; code is unchanged on main @ 55e76dac
    (2026-08-24)
    and on the v1.11.0 tag.
  • github.com/livekit/sipgo v0.13.2-0.20260519205735-a5b4a38b6ceb (the pin on main)
  • github.com/emiago/sipgo v1.4.0

livekit/sipgo/sip is a thin alias layer over emiago/sipgo/sip, so Request.Destination()
and DefaultPort() below are emiago's.

Reproduction

A zone provisioned per RFC 3263 — SRV records for both UDP and TLS, plus an A record on the
domain itself (e.g. for the web/API endpoint sharing the name):

sip.example.com.                    A     198.51.100.84
sip.example.com.                    A     198.51.100.85
_sip._udp.sip.example.com.          SRV   10 100 5060 sip1.sip.example.com.
_sip._udp.sip.example.com.          SRV   10 100 5060 sip2.sip.example.com.
_sips._tcp.sip.example.com.         SRV   10 100 5061 sip1.sip.example.com.
_sips._tcp.sip.example.com.         SRV   10 100 5061 sip2.sip.example.com.
sip1.sip.example.com.               A     198.51.100.84
sip2.sip.example.com.               A     198.51.100.85

Create an outbound trunk with --address sip.example.com (no explicit port), for
--transport udp and again for --transport tls.

Expected (RFC 3263 §4.2): sips: / TLS resolves via _sips._tcp, sip: / UDP via
_sip._udp; the SRV target is then A-resolved, and the SRV port, priority and weight
govern target selection and failover.

Actual: both resolve straight to the first A record of the domain, at the hardcoded default
port. No SRV query is ever issued.

sip:sip.example.com               transport=udp -> 198.51.100.84:5060   [A record; SRV never queried]
sip:sip.example.com;transport=tcp transport=tcp -> 198.51.100.84:5060   [A record; SRV never queried]
sips:sip.example.com              transport=tls -> 198.51.100.84:5061   [A record; SRV never queried]

I reproduced this by running resolveAddr's logic verbatim inside a container built from this
tree, against live DNS; the tracing below is what connects it to the real outbound call path.

Root cause

(1) Port is decided before any DNS lookup.

URI.GetURI() leaves Port unset when the trunk address carries no explicit port
(pkg/sip/types.go#L160-L172).
Request.Destination() then fills it in from a hardcoded table
(emiago/sipgo sip/request.go#L226-L231
sip/transport.go#L51-L66).
URI.GetPort() does the same for other call sites
(pkg/sip/types.go#L127-L137):

func (u URI) GetPort() int {
	port := int(u.Addr.Port())
	if port == 0 {
		if u.Transport == TransportTLS {
			port = 5061
		} else {
			port = 5060
		}
	}
	return port
}

By the time ClientRequestConnection calls sip.ParseAddr(req.Destination()), the port is
already 5060/5061. Even a working SRV lookup could not change it.

(2) A/AAAA short-circuits the SRV lookup.

livekit/sipgo transport/layer.go#L396-L420:

func (l *Layer) resolveAddr(ctx context.Context, network string, host string, addr *Addr) error {
	// We need to try local resolving.
	ip, err := net.ResolveIPAddr("ip", host)
	if err == nil {
		addr.IP = ip.IP
		return nil          // <-- SRV is never attempted for any host with an A record
	}
	...
	_, addrs, err := l.dnsResolver.LookupSRV(ctx, "sip", lookupnet, host)

net.ResolveIPAddr is an A/AAAA lookup, so SRV is positioned as a fallback for names that
don't resolve
. RFC 3263 §4.2 branches on the syntax of the URI, not on what DNS returns:

If the TARGET was not a numeric IP address, and no port was present in the URI, the client
performs an SRV query [...]

If no SRV records were found, the client performs an A or AAAA record lookup of the
domain name.

The RFC's short-circuits are an IP literal (no DNS at all) and an explicit port in the URI
(A/AAAA, SRV skipped) — the presence of an A record is never a signal it consults for ordering.
resolveAddr substitutes "did the A/AAAA query succeed?" for "does the URI carry a port?".
Those two tests agree only for domains that have no A record.

This inverts the RFC's intent in a way that penalises correct provisioning: under RFC 3263 an A
record alongside SRV is the recommended belt-and-braces setup, present so that clients finding
no SRV still reach the domain. Here, publishing that A record is exactly what disables SRV —
the spec's fallback becomes this implementation's fast path.

(3) The SRV fallback cannot produce a usable address.

Same function, layer.go#L417-L419:

a := addrs[0]
addr.IP = net.ParseIP(a.Target[:len(a.Target)-1])
addr.Port = int(a.Port)

a.Target is a hostname (sip2.sip.example.com.), not an IP. There is no second lookup, so
net.ParseIP returns nil. Confirmed against live DNS:

lookupnet=udp -> SRV target="sip2.sip.example.com." port=5060
              ==> net.ParseIP("sip2.sip.example.com") = <nil>

addrs[0] is also taken unconditionally — Priority and Weight are discarded, and there is
no failover to addrs[1].

(4) Service name.

LookupSRV(ctx, "sip", lookupnet, host) hardcodes the sip service, so _sips._tcp is never
queried — TLS trunks look for _sip._tcp. RFC 3263 §4.2 requires the _sips service
identifier for SIPS URIs, and for a SIP URI where the client wishes to use TLS. This is what
#458 hit.

(NAPTR is also never queried, but that is a minor point here: §4.1 only calls for NAPTR when
neither transport nor port is specified, and trunks always specify a transport, so a
conformant client would legitimately skip it in this path too.)

Impact

For a zone like the one above the failure is silent rather than loud: the hardcoded ports
happen to match the SRV ports, and the SRV targets happen to resolve to the same IPs the parent
A record returns, so calls connect and nothing looks wrong. What is actually lost is every
property SRV was provisioned for — priority ordering, weighted distribution, and failover to
the second target when the first is down.

It becomes a hard failure when the SRV target uses a non-default port, or points at hosts
outside the parent A set. And per #458, a domain with SRV but no A record fails outright with
fail to resolve target for ...: lookup _sip._tcp....: no such host — which is defect (2)'s
fallback firing and then hitting defect (4), and would hit defect (3) even if the service name
were right.

Suggested fix

All four live in livekit/sipgo's resolveAddr, plus the port plumbing in this repo:

  1. Invert the order: attempt SRV before A/AAAA when the Request-URI has no explicit port
    (RFC 3263 §4.2 — an explicit port disables SRV, an explicit IP disables DNS entirely).
  2. Select service name by transport: _sips._tcp for TLS, _sip._tcp for TCP, _sip._udp
    for UDP (§4.2). NAPTR is optional — §4.1 only requires it when neither transport nor port
    is specified, which trunks never hit.
  3. A-resolve the chosen SRV Target instead of net.ParseIP-ing it.
  4. Honour Priority/Weight for selection, and fall through to the next target on
    connect failure.
  5. Let the resolved SRV port reach the destination — today Destination() bakes in
    DefaultPort() before the transport layer can override it.

Happy to put up a PR against livekit/sipgo if that's a direction you'd take.

Related: #458 (closed as not-planned) — same zone shape, narrower diagnosis.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions