From dad2227d2d3934ede4b8c805cbe2a12b1275c133 Mon Sep 17 00:00:00 2001 From: jaylim95 Date: Tue, 25 Aug 2026 12:30:07 +0800 Subject: [PATCH] Resolve outbound destinations with DNS SRV 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) --- pkg/config/config.go | 4 + pkg/sip/client.go | 12 +++ pkg/sip/dns.go | 116 ++++++++++++++++++++++ pkg/sip/dns_test.go | 150 +++++++++++++++++++++++++++++ pkg/sip/outbound.go | 39 ++++++++ pkg/sip/outbound_test.go | 86 +++++++++++++++++ pkg/sip/outbound_utilities_test.go | 44 +++++---- 7 files changed, 434 insertions(+), 17 deletions(-) create mode 100644 pkg/sip/dns.go create mode 100644 pkg/sip/dns_test.go diff --git a/pkg/config/config.go b/pkg/config/config.go index 8192660d3..16c55cd39 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -134,6 +134,10 @@ type Config struct { DisableRejectedInviteCache bool `yaml:"disable_rejected_invite_cache"` // AddRecordRoute forces SIP to add Record-Route headers to the responses. AddRecordRoute bool `yaml:"add_record_route"` + // DisableDNSSRV turns off DNS SRV lookups for outbound destinations, so a + // hostname without an explicit port is resolved with a plain address lookup + // at the transport's default port. + DisableDNSSRV bool `yaml:"disable_dns_srv"` // AudioDTMF forces SIP to generate audio DTMF tones in addition to digital. AudioDTMF bool `yaml:"audio_dtmf"` diff --git a/pkg/sip/client.go b/pkg/sip/client.go index fa64bebdb..2b8099432 100644 --- a/pkg/sip/client.go +++ b/pkg/sip/client.go @@ -67,6 +67,7 @@ type Client struct { mon *stats.Monitor sipCli SIPClient + dns DNSResolver closing core.Fuse cmu sync.Mutex @@ -88,6 +89,16 @@ func WithGetSipClient(fn GetSipClientFunc) ClientOption { } } +// WithDNSResolver overrides the resolver used to locate outbound destinations. +// Defaults to net.DefaultResolver. +func WithDNSResolver(r DNSResolver) ClientOption { + return func(c *Client) { + if r != nil { + c.dns = r + } + } +} + func WithGetRoomClient(fn GetRoomFunc) ClientOption { return func(c *Client) { if fn != nil { @@ -108,6 +119,7 @@ func NewClient(region string, conf *config.Config, log logger.Logger, mon *stats getStateHandler: getStateHandler, getSipClient: DefaultGetSipClientFunc, getRoom: DefaultGetRoomFunc, + dns: net.DefaultResolver, activeCalls: make(map[LocalTag]*outboundCall), } for _, option := range options { diff --git a/pkg/sip/dns.go b/pkg/sip/dns.go new file mode 100644 index 000000000..286911f6d --- /dev/null +++ b/pkg/sip/dns.go @@ -0,0 +1,116 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sip + +import ( + "context" + "net" + "net/netip" + "strconv" + "strings" + + "github.com/livekit/sipgo/sip" +) + +// DNSResolver is the subset of *net.Resolver used to locate a SIP next hop. +// It is an interface so that tests can stub DNS out. +type DNSResolver interface { + LookupSRV(ctx context.Context, service, proto, name string) (string, []*net.SRV, error) + LookupIPAddr(ctx context.Context, host string) ([]net.IPAddr, error) +} + +var _ DNSResolver = (*net.Resolver)(nil) + +// srvLabels returns the RFC 3263 service and protocol labels for a SIP +// transport, i.e. the "sip" and "udp" of an _sip._udp.example.com SRV lookup. +// ok is false for transports that have no SRV mapping. +func srvLabels(transport string) (service, proto string, ok bool) { + switch strings.ToLower(transport) { + case "udp": + return "sip", "udp", true + case "tcp": + return "sip", "tcp", true + case "tls": + return "sips", "tcp", true + case "ws": // RFC 7118 + return "sip", "ws", true + case "wss": // RFC 7118 + return "sips", "wss", true + } + return "", "", false +} + +// resolveNextHop returns the "ip:port" transport destination for a SIP next +// hop, following the DNS procedures of RFC 3263 section 4. +// +// SRV records are only consulted when the URI carries no explicit port +// (RFC 3263 section 4.2): a numeric port, like an IP literal host, means the +// hop has already been selected and DNS must not override it. When the target +// publishes no usable SRV record, resolution falls back to a plain address +// lookup of host at the transport's default port, which is what the transport +// layer would have done on its own. +// +// NAPTR (RFC 3263 section 4.1) is deliberately not implemented: the transport +// is always already known here, either from the trunk configuration or from +// the URI's transport parameter, so there is nothing left for NAPTR to select. +func resolveNextHop(ctx context.Context, r DNSResolver, host string, port int, transport string) (string, error) { + if r == nil { + r = net.DefaultResolver + } + if ip, err := netip.ParseAddr(host); err == nil { + if port == 0 { + port = sip.DefaultPort(transport) + } + return netip.AddrPortFrom(ip, uint16(port)).String(), nil + } + if port != 0 { + return resolveHost(ctx, r, host, port) + } + if service, proto, ok := srvLabels(transport); ok { + if _, srvs, err := r.LookupSRV(ctx, service, proto, host); err == nil { + // LookupSRV already orders records by priority and shuffles them by + // weight, so the first target that resolves is the one to use. + for _, srv := range srvs { + target := strings.TrimSuffix(srv.Target, ".") + if target == "" { + // A lone "." target means "no service here" (RFC 2782). Treat + // it as an unusable record and fall back to an address lookup, + // rather than failing the call outright. + break + } + dest, err := resolveHost(ctx, r, target, int(srv.Port)) + if err != nil { + continue // try the next target + } + return dest, nil + } + } + } + return resolveHost(ctx, r, host, sip.DefaultPort(transport)) +} + +// resolveHost resolves host to a single "ip:port" destination. It picks the +// first address returned, which is the one the resolver considers preferable +// under RFC 6724, matching what net.ResolveIPAddr would have selected. +func resolveHost(ctx context.Context, r DNSResolver, host string, port int) (string, error) { + addrs, err := r.LookupIPAddr(ctx, host) + if err != nil { + return "", err + } + if len(addrs) == 0 { + return "", &net.DNSError{Err: "no such host", Name: host, IsNotFound: true} + } + return net.JoinHostPort(addrs[0].IP.String(), strconv.Itoa(port)), nil +} diff --git a/pkg/sip/dns_test.go b/pkg/sip/dns_test.go new file mode 100644 index 000000000..5f18bc776 --- /dev/null +++ b/pkg/sip/dns_test.go @@ -0,0 +1,150 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sip + +import ( + "context" + "net" + "testing" + + "github.com/stretchr/testify/require" +) + +// fakeDNS is a DNSResolver backed by static records. Lookups that miss return +// NXDOMAIN, so a zero fakeDNS resolves nothing and keeps tests off the network. +type fakeDNS struct { + srv map[string][]*net.SRV // keyed by the full name, e.g. "_sip._udp.example.com" + addr map[string][]net.IPAddr // keyed by host +} + +func (f fakeDNS) LookupSRV(_ context.Context, service, proto, name string) (string, []*net.SRV, error) { + full := "_" + service + "._" + proto + "." + name + recs, ok := f.srv[full] + if !ok { + return "", nil, &net.DNSError{Err: "no such host", Name: full, IsNotFound: true} + } + return full, recs, nil +} + +func (f fakeDNS) LookupIPAddr(_ context.Context, host string) ([]net.IPAddr, error) { + addrs, ok := f.addr[host] + if !ok { + return nil, &net.DNSError{Err: "no such host", Name: host, IsNotFound: true} + } + return addrs, nil +} + +func ipAddrs(ips ...string) []net.IPAddr { + out := make([]net.IPAddr, 0, len(ips)) + for _, ip := range ips { + out = append(out, net.IPAddr{IP: net.ParseIP(ip)}) + } + return out +} + +func TestResolveNextHop(t *testing.T) { + dns := fakeDNS{ + srv: map[string][]*net.SRV{ + "_sip._udp.example.com": {{Target: "udp1.example.com.", Port: 5080}}, + "_sip._tcp.example.com": {{Target: "tcp1.example.com.", Port: 5081}}, + "_sips._tcp.example.com": {{Target: "tls1.example.com.", Port: 5082}}, + // First target does not resolve, so the second one wins. + "_sip._udp.failover.com": { + {Target: "missing.example.com.", Port: 5090}, + {Target: "udp1.example.com.", Port: 5091}, + }, + // RFC 2782 "service decidedly not available at this domain". + "_sip._udp.nosrv.com": {{Target: ".", Port: 0}}, + }, + addr: map[string][]net.IPAddr{ + "example.com": ipAddrs("192.0.2.1"), + "udp1.example.com": ipAddrs("192.0.2.10"), + "tcp1.example.com": ipAddrs("192.0.2.11"), + "tls1.example.com": ipAddrs("192.0.2.12"), + "failover.com": ipAddrs("192.0.2.2"), + "nosrv.com": ipAddrs("192.0.2.3"), + "plain.com": ipAddrs("192.0.2.4", "192.0.2.5"), + "v6.example.com": ipAddrs("2001:db8::1"), + }, + } + + cases := []struct { + name string + host string + port int + transport string + exp string + expErr bool + }{ + {name: "ip literal", host: "192.0.2.1", transport: "UDP", exp: "192.0.2.1:5060"}, + {name: "ip literal with port", host: "192.0.2.1", port: 5080, transport: "UDP", exp: "192.0.2.1:5080"}, + {name: "ip literal tls default port", host: "192.0.2.1", transport: "TLS", exp: "192.0.2.1:5061"}, + {name: "ipv6 literal", host: "2001:db8::1", transport: "UDP", exp: "[2001:db8::1]:5060"}, + + // An explicit port means the hop is already chosen: RFC 3263 sec 4.2 says + // not to look at SRV, even though example.com publishes records. + {name: "explicit port skips srv", host: "example.com", port: 5070, transport: "UDP", exp: "192.0.2.1:5070"}, + + {name: "srv udp", host: "example.com", transport: "UDP", exp: "192.0.2.10:5080"}, + {name: "srv tcp", host: "example.com", transport: "TCP", exp: "192.0.2.11:5081"}, + {name: "srv tls uses _sips._tcp", host: "example.com", transport: "TLS", exp: "192.0.2.12:5082"}, + + {name: "srv target that does not resolve is skipped", host: "failover.com", transport: "UDP", exp: "192.0.2.10:5091"}, + {name: "dot target falls back to address lookup", host: "nosrv.com", transport: "UDP", exp: "192.0.2.3:5060"}, + + {name: "no srv falls back to address lookup", host: "plain.com", transport: "UDP", exp: "192.0.2.4:5060"}, + {name: "no srv tls fallback uses 5061", host: "plain.com", transport: "TLS", exp: "192.0.2.4:5061"}, + {name: "ipv6 address record", host: "v6.example.com", transport: "UDP", exp: "[2001:db8::1]:5060"}, + + {name: "unresolvable", host: "nowhere.com", transport: "UDP", expErr: true}, + {name: "unresolvable with explicit port", host: "nowhere.com", port: 5060, transport: "UDP", expErr: true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, err := resolveNextHop(context.Background(), dns, c.host, c.port, c.transport) + if c.expErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, c.exp, got) + }) + } +} + +func TestSRVLabels(t *testing.T) { + cases := []struct { + transport string + service string + proto string + ok bool + }{ + {transport: "UDP", service: "sip", proto: "udp", ok: true}, + {transport: "udp", service: "sip", proto: "udp", ok: true}, + {transport: "TCP", service: "sip", proto: "tcp", ok: true}, + {transport: "TLS", service: "sips", proto: "tcp", ok: true}, + {transport: "WS", service: "sip", proto: "ws", ok: true}, + {transport: "WSS", service: "sips", proto: "wss", ok: true}, + {transport: "sctp", ok: false}, + } + for _, c := range cases { + t.Run(c.transport, func(t *testing.T) { + service, proto, ok := srvLabels(c.transport) + require.Equal(t, c.ok, ok) + require.Equal(t, c.service, service) + require.Equal(t, c.proto, proto) + }) + } +} diff --git a/pkg/sip/outbound.go b/pkg/sip/outbound.go index 89ef94b19..f57862d82 100644 --- a/pkg/sip/outbound.go +++ b/pkg/sip/outbound.go @@ -878,6 +878,7 @@ type sipOutbound struct { mu sync.RWMutex tag RemoteTag callID string + dest string // resolved next hop, see resolveDest invite *sip.Request inviteOk *sip.Response localSDP []byte // SDP Offer, constrained by the answer @@ -1173,6 +1174,10 @@ func (c *sipOutbound) attemptInvite(ctx context.Context, callID sip.CallIDHeader req.PrependHeader(sip.NewHeader("Route", route)) } + if dest := c.resolveDest(ctx, req); dest != "" { + req.SetDestination(dest) + } + tx, err := c.c.sipCli.TransactionRequest(req) if err != nil { return nil, nil, err @@ -1211,6 +1216,40 @@ func (c *sipOutbound) attemptInvite(ctx context.Context, callID sip.CallIDHeader return req, resp, err } +// resolveDest returns the address the request should be sent to, resolving the +// next hop (the first Route header if there is one, the request URI otherwise) +// with SRV support. Pinning it on the request keeps the transport layer from +// resolving the name itself, and makes the ACK, CANCEL and BYE built from this +// request reach the same host the INVITE did. +// +// The result is cached because Invite retries the INVITE after an auth +// challenge: a second lookup could land on a different SRV target, where the +// nonce from the 401/407 is not valid. +// +// 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 +// used to. +func (c *sipOutbound) resolveDest(ctx context.Context, req *sip.Request) string { + if c.c.conf.DisableDNSSRV { + return "" + } + if c.dest != "" { + return c.dest + } + uri := &req.Recipient + if h := req.Route(); h != nil { + uri = &h.Address + } + dest, err := resolveNextHop(ctx, c.c.dns, uri.Host, uri.Port, req.Transport()) + if err != nil { + c.log.Debugw("cannot resolve outbound destination, deferring to transport layer", + "host", uri.Host, "port", uri.Port, "transport", req.Transport(), "error", err) + return "" + } + c.dest = dest + return dest +} + func (c *sipOutbound) WriteRequest(req *sip.Request) error { return c.c.sipCli.WriteRequest(req) } diff --git a/pkg/sip/outbound_test.go b/pkg/sip/outbound_test.go index e01a1953f..a174a398a 100644 --- a/pkg/sip/outbound_test.go +++ b/pkg/sip/outbound_test.go @@ -17,6 +17,7 @@ package sip import ( "context" "fmt" + "net" "testing" "time" @@ -295,6 +296,91 @@ func TestOutboundACKDestinationAfterInviteResponse(t *testing.T) { }) } +func TestOutboundINVITEUsesSRVDestination(t *testing.T) { + // sip.example.com publishes an SRV record, so the INVITE must go to the SRV + // target and port rather than to the A record on the default port. + dns := fakeDNS{ + srv: map[string][]*net.SRV{ + "_sip._udp." + testInviteTargetHost: {{Target: "edge1.example.com.", Port: 5080}}, + }, + addr: map[string][]net.IPAddr{ + "edge1.example.com": ipAddrs("192.0.2.10"), + testInviteTargetHost: ipAddrs("192.0.2.1"), + }, + } + const srvDest = "192.0.2.10:5080" + + _, tr, ackReq := waitOutboundINVITEAndACK(t, TestClientConfig{DNS: dns}, MinimalCreateSIPParticipantRequest(), func(tr *transactionRequest, resp *sip.Response) { + // Assert here, while the INVITE is in flight: applyInviteResponse may + // rewrite the destination once the response is handled. + require.Equal(t, srvDest, tr.req.Destination()) + // The SRV target only picks the hop; the request URI keeps the hostname. + require.Equal(t, testInviteTargetHost, tr.req.Recipient.Host) + + resp.AppendHeader(&sip.ContactHeader{Address: sip.Uri{Host: testInviteTargetHost, Port: 5060}}) + }) + require.NotNil(t, tr) + require.NotNil(t, ackReq) + + // The ACK follows the INVITE to the same host instead of resolving again. + require.Equal(t, srvDest, ackReq.req.Destination()) +} + +func TestOutboundINVITERouteHeaderIsTheNextHop(t *testing.T) { + // With an outbound proxy configured, the Route header is the next hop, so it + // is the proxy that gets resolved, not the request URI. Route headers are + // added as generic headers, but Route() parses those lazily, so the next hop + // is picked the same way the transport layer picks it. + conf := minimalTestConfig(t) + conf.OutboundRouteHeaders = []string{""} + cfg := TestClientConfig{ + Config: conf, + DNS: fakeDNS{ + srv: map[string][]*net.SRV{ + "_sip._udp.proxy.example.com": {{Target: "edge-proxy.example.com.", Port: 5090}}, + "_sip._udp." + testInviteTargetHost: {{Target: "edge1.example.com.", Port: 5080}}, + }, + addr: map[string][]net.IPAddr{ + "edge-proxy.example.com": ipAddrs("192.0.2.20"), + "edge1.example.com": ipAddrs("192.0.2.10"), + testInviteTargetHost: ipAddrs("192.0.2.1"), + }, + }, + } + + participantReq := MinimalCreateSIPParticipantRequest() + participantReq.FeatureFlags = map[string]string{outboundRouteHeadersFeatureFlag: "true"} + + waitOutboundINVITEAndACK(t, cfg, participantReq, func(tr *transactionRequest, resp *sip.Response) { + require.NotNil(t, tr.req.Route(), "Route header should be visible through the typed accessor") + require.Equal(t, "192.0.2.20:5090", tr.req.Destination()) + require.Equal(t, testInviteTargetHost, tr.req.Recipient.Host) + + resp.AppendHeader(&sip.ContactHeader{Address: sip.Uri{Host: testInviteTargetHost, Port: 5060}}) + }) +} + +func TestOutboundINVITEDNSSRVDisabled(t *testing.T) { + // With SRV lookups disabled the destination is left to the transport layer. + conf := minimalTestConfig(t) + conf.DisableDNSSRV = true + cfg := TestClientConfig{ + Config: conf, + DNS: fakeDNS{ + srv: map[string][]*net.SRV{ + "_sip._udp." + testInviteTargetHost: {{Target: "edge1.example.com.", Port: 5080}}, + }, + addr: map[string][]net.IPAddr{"edge1.example.com": ipAddrs("192.0.2.10")}, + }, + } + + waitOutboundINVITEAndACK(t, cfg, MinimalCreateSIPParticipantRequest(), func(tr *transactionRequest, resp *sip.Response) { + // Destination() falls back to the request URI, so check the pinned field. + require.Empty(t, tr.req.MessageData.Destination()) + resp.AppendHeader(&sip.ContactHeader{Address: sip.Uri{Host: testInviteTargetHost, Port: 5060}}) + }) +} + // sipResponse returns immediately on a cancelled context, sending a CANCEL. func TestSIPResponseCancelReturnsImmediately(t *testing.T) { tx := &testSIPClientTransaction{ diff --git a/pkg/sip/outbound_utilities_test.go b/pkg/sip/outbound_utilities_test.go index 785508075..cc47e51f5 100644 --- a/pkg/sip/outbound_utilities_test.go +++ b/pkg/sip/outbound_utilities_test.go @@ -480,6 +480,28 @@ type TestClientConfig struct { GetSipClient GetSipClientFunc // NewTestClientFunc if nil GetRoom GetRoomFunc // newTestRoom if nil Handler Handler // empty TestHandler if nil + DNS DNSResolver // resolves nothing if nil, so tests stay off the network +} + +// minimalTestConfig returns the config NewOutboundTestClient uses by default. +func minimalTestConfig(t testing.TB) *config.Config { + t.Helper() + localIP, err := config.GetLocalIP() + if err != nil { + t.Fatalf("failed to get local IP: %v", err) + } + return &config.Config{ + NodeID: "test-node", + SIPPort: 5060, + SIPPortListen: 5060, + ListenIP: localIP.String(), + LocalNet: localIP.String() + "/24", + RTPPort: rtcconfig.PortRange{Start: 20000, End: 30000}, + MaxCpuUtilization: 0.99, // Higher threshold for tests to avoid false positives + WsUrl: "ws://localhost:7880", + ApiKey: "test-api-key", + ApiSecret: "test-api-secret-extend-to-32-bytes-minimum", + } } func NewOutboundTestClient(t testing.TB, cfg TestClientConfig) *Client { @@ -489,22 +511,7 @@ func NewOutboundTestClient(t testing.TB, cfg TestClientConfig) *Client { } log := logger.NewTestLogger(t) if cfg.Config == nil { - localIP, err := config.GetLocalIP() - if err != nil { - t.Fatalf("failed to get local IP: %v", err) - } - cfg.Config = &config.Config{ - NodeID: "test-node", - SIPPort: 5060, - SIPPortListen: 5060, - ListenIP: localIP.String(), - LocalNet: localIP.String() + "/24", - RTPPort: rtcconfig.PortRange{Start: 20000, End: 30000}, - MaxCpuUtilization: 0.99, // Higher threshold for tests to avoid false positives - WsUrl: "ws://localhost:7880", - ApiKey: "test-api-key", - ApiSecret: "test-api-secret-extend-to-32-bytes-minimum", - } + cfg.Config = minimalTestConfig(t) } if cfg.Monitor == nil { var err error @@ -543,7 +550,10 @@ func NewOutboundTestClient(t testing.TB, cfg TestClientConfig) *Client { if cfg.Handler == nil { cfg.Handler = &TestHandler{} } - client := NewClient(cfg.Region, cfg.Config, log, cfg.Monitor, cfg.GetIOClient, WithGetSipClient(cfg.GetSipClient), WithGetRoomClient(cfg.GetRoom)) + if cfg.DNS == nil { + cfg.DNS = fakeDNS{} + } + client := NewClient(cfg.Region, cfg.Config, log, cfg.Monitor, cfg.GetIOClient, WithGetSipClient(cfg.GetSipClient), WithGetRoomClient(cfg.GetRoom), WithDNSResolver(cfg.DNS)) client.SetHandler(cfg.Handler) // Set up service config with minimal values