This commit is contained in:
bluepython508 committed 2026-10-22 12:27:19 +01:00
1 parent 3530afb854
commit 6903fcff27
2146 files changed
+112256 -190703

No files matched your search

+64 -6
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package bakedroots contains WebPKI CA roots we bake into the tailscaled binary,
@@ -15,13 +15,11 @@ import (
// Get returns the baked-in roots.
//
// As of 2025-01-21, this includes only the LetsEncrypt ISRG Root X1 & X2 roots.
// As of 2026-07-20, this includes only the LetsEncrypt ISRG roots:
// the Generation X roots (X1 & X2) and the Generation Y roots (YE & YR).
func Get() *x509.CertPool {
roots.once.Do(func() {
roots.parsePEM(append(
[]byte(letsEncryptX1),
letsEncryptX2...,
))
roots.parsePEM([]byte(letsEncryptX1 + letsEncryptX2 + letsEncryptYE + letsEncryptYR))
})
return roots.p
}
@@ -145,3 +143,63 @@ tL4ndQavEi51mI38AjEAi/V3bNTIZargCyzuFJ0nN6T5U6VR5CmD1/iQMVtCnwr1
/q4AaOeMSQ+2b1tbFfLn
-----END CERTIFICATE-----
`
// letsEncryptYE is the ISRG Root YE, part of LetsEncrypt's
// "Generation Y" hierarchy announced 2025-11-24.
//
// Subject: C = US, O = ISRG, CN = Root YE
// Key type: ECDSA P-384
// Validity: 2025-09-03 to 2045-09-02
const letsEncryptYE = `
-----BEGIN CERTIFICATE-----
MIIB2TCCAWCgAwIBAgIRAKQCa6LvbHwg1AR+XmWmk4AwCgYIKoZIzj0EAwMwLjEL
MAkGA1UEBhMCVVMxDTALBgNVBAoTBElTUkcxEDAOBgNVBAMTB1Jvb3QgWUUwHhcN
MjUwOTAzMDAwMDAwWhcNNDUwOTAyMjM1OTU5WjAuMQswCQYDVQQGEwJVUzENMAsG
A1UEChMESVNSRzEQMA4GA1UEAxMHUm9vdCBZRTB2MBAGByqGSM49AgEGBSuBBAAi
A2IABDwS/6vhrcVqcbBo+wgdI3fwn9x7DNJJOY/lTOti0vkwuRN87RhEhTH17E7X
yFjWsPYhIPt/wzOqxTd2b+4ZJNy9ID04YywF9U5zasDVyGSNErVNtz8uSGh5izW8
7j77GaNCMEAwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0O
BBYEFKPIJlqOoUzQNWP8myPIOq5W809WMAoGCCqGSM49BAMDA2cAMGQCMHhMr8N9
LdL1VQKs9BdV81r76eXRB6mtjuNjzk6/lBsPNToWLTDzGYgtQKO1jl63uAIwGV7m
onyF377c+MM1oqVNs17sgu7F9YKZwgLmVbeOMDbKAXHtKMDLbiGllCcs8f47
-----END CERTIFICATE-----
`
// letsEncryptYR is the ISRG Root YR, part of LetsEncrypt's
// "Generation Y" hierarchy announced 2025-11-24.
//
// Subject: C = US, O = ISRG, CN = Root YR
// Key type: RSA 4096
// Validity: 2025-09-03 to 2045-09-02
const letsEncryptYR = `
-----BEGIN CERTIFICATE-----
MIIFKTCCAxGgAwIBAgIRAOxGNJNgz0sP+KmC2Tqpyj0wDQYJKoZIhvcNAQELBQAw
LjELMAkGA1UEBhMCVVMxDTALBgNVBAoTBElTUkcxEDAOBgNVBAMTB1Jvb3QgWVIw
HhcNMjUwOTAzMDAwMDAwWhcNNDUwOTAyMjM1OTU5WjAuMQswCQYDVQQGEwJVUzEN
MAsGA1UEChMESVNSRzEQMA4GA1UEAxMHUm9vdCBZUjCCAiIwDQYJKoZIhvcNAQEB
BQADggIPADCCAgoCggIBANvGJnN78CTJdWL3+eGfsLN5TrNBJs+VH9hRXqRbwxu9
sGNiB0BD1fcOxbSUQCJIM1xE13Db+5Cw1w0s0EBYsvuIP/6joF0w8cuImbgR1OGg
YbSQ4OpzI+DG8SGuTlcE873OCS+kh3srlo6vl43M5OJg4Aeo1sfHp6kTJDoIiFBN
JAY+OKfX/FUvYKuhjT+no49lmqmupSBI5PkBQiqrEGtWU5uxU/cQWHGu8jSjFBzn
ZqvbNPLMXMLFxCb3WTfrJBXXjqvWG+v4bjzxjjeAtOlU7qarRDvNOyAuQYLln904
M+faKx8hnLCpJ15ZqaEgcNlY+9MMWcC5yvL2A2j3l9+2buggZX+dOE91zYmIdawT
vSZuVvlbRrAlLxIB6pwMBjneXCjYQ8+3BCCjssbSNpZU3hTcBDdhfAlEDlYr6pEa
tnMdmDT5BqnKC92bd0EhM1fbLHioLccLCuievT8ZkPhZrq7Mii7gNXAcUEAR8+lz
Yal+9zTg7C5DALyVOeG/CqfRAMn1KSHCR0NSA6P8tn/mGRlnCct5rtVCLnVySVpU
6H1qGg3DgTOuskf8eahTMiYbI5ezPJmO5ertalskQ1utp74+eDy92PI4ftHKTbq9
IWhH4YZKh3WnJEIt+oQvlYZbY8tpEroKrFB6PFGzrJIDRyts4HqvuH52RFj2zv/B
AgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1Ud
DgQWBBTe51tg0CJtQCh9Pw0B/qS1UrRRlDANBgkqhkiG9w0BAQsFAAOCAgEAWHnf
713Bdkq7t5yN2dNIgQakUb94X9WuyhMEHHkgx4oDpSUlnG0w4g94MoqaEUE31ZjR
LU7L5LD1g9ujFHTQu8AD215AHMVQFbm6j8hQxdXHAzDajFNQnOlDJrLjzIx176oy
AjvUtejZx2NNmdb5fd0WGVGsCdoAJ3N8ozo7ajE8t6vfxStZb4BQ9WYJGHUDrv2N
i5tJF6CNiPnlzs3BUfECRbE4JSk+jvy8+VoGiFE8qsH/j78x2fjgQhAQFV7P7Zxy
dBTZ1wEkNpZNW2qnaK1SKBLa+xf6E06YRIq5uaI+HWH8SY1y5VbRgzq40EKg3yxP
06fz+uYAUIFJoLNfhwRCc3Q6pQVuMX3yAjHAes4gk4moGcLQ5p7HAh39yeylZc1J
41sx/jKwLIkPE6Rr1Nf4pxdsxf9SA4yOEiAkDgq04DVxn8hgYFdUtBCuiuVC2heA
EiqVEa+8QZjuw8Gj0EbHXcRd1nInvGqRS1o9Is7YBdQN57X1AYveGBNNqjICSb7c
awuw1EawTDrs13VUlJVEsbQ0/O/1aaV73mCdOQ8azqL2KTv1Ewu1xbquE2S+kdQU
To9TUwat3wUA6cwXh1EfpS/3fJ0aGah5hdpRyoCLDlsSn8tkrjMfFFX0viC+GxHc
sI1ANRYvqSFC2X1VRZfDg+wD6E21BccmifG4yWc=
-----END CERTIFICATE-----
`
+45 -8
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package batching implements a socket optimized for increased throughput.
@@ -19,27 +19,64 @@ var (
_ ipv6.Message = ipv4.Message{}
)
// Conn is a nettype.PacketConn that provides batched i/o using
// ReceivedPacket describes a packet read by [Conn.ReadBatch].
type ReceivedPacket struct {
// Offset is the starting byte offset into the slab supplied to [Conn.ReadBatch].
Offset int
// Size is the size of the packet.
Size int
// Source is the source address that sent the packet.
Source netip.AddrPort
}
const (
// ReadSlabMultiple is the minimum slab length accepted by [Conn.ReadBatch],
// and is the suggested multiple when passing a larger value.
ReadSlabMultiple = 1<<16 - 1
// MinimumReadBatchSize is the minimum number of packets descriptors accepted
// by [Conn.ReadBatch].
MinimumReadBatchSize = udpGROCountMax
// MaximumWriteBatchSize is the maximum number of buffs accepted by
// [Conn.WriteBatchTo].
MaximumWriteBatchSize = 128
// udpGROCountMax is the maximum number of datagrams the kernel will coalesce
// together to present in a single recvmmsg() slot.
udpGROCountMax = 64
)
// Conn is a [nettype.PacketConn] that provides batched i/o using
// platform-specific optimizations, e.g. {recv,send}mmsg & UDP GSO/GRO.
//
// Conn does not support single packet reads (see ReadFromUDPAddrPort docs). It
// is the caller's responsibility to use the appropriate read API where a
// [nettype.PacketConn] has been upgraded to support batched i/o.
//
// Conn originated from (and is still used by) magicsock where its API was
// strongly influenced by [wireguard-go/conn.Bind] constraints, namely
// wireguard-go's ownership of packet memory.
type Conn interface {
nettype.PacketConn
// ReadBatch reads messages from [Conn] into msgs. It returns the number of
// messages the caller should evaluate for nonzero len, as a zero len
// message may fall on either side of a nonzero.
// ReadFromUDPAddrPort always returns an error, as UDP GRO is incompatible
// with single packet reads. A single datagram may be multiple, coalesced
// datagrams, and this API lacks the ability to pass that context.
//
// Each [ipv6.Message.OOB] must be sized to at least MinControlMessageSize().
ReadBatch(msgs []ipv6.Message, flags int) (n int, err error)
// TODO: consider detaching Conn from [nettype.PacketConn]
ReadFromUDPAddrPort([]byte) (int, netip.AddrPort, error)
// ReadBatch reads datagrams from [Conn] into slab, and describes them in
// packets. It returns the number of populated packet descriptors. A single
// GRO-coalesced datagram may produce multiple descriptors.
//
// packets must have a length >= [MinimumReadBatchSize]. slab must have a
// length >= [ReadSlabMultiple]. ReadBatch reads only as many datagrams as
// both arguments can accommodate.
ReadBatch(slab []byte, packets []ReceivedPacket) (n int, err error)
// WriteBatchTo writes buffs to addr.
//
// If geneve.VNI.IsSet(), then geneve is encoded into the space preceding
// offset, and offset must equal [packet.GeneveFixedHeaderLength]. If
// !geneve.VNI.IsSet() then the space preceding offset is ignored.
//
// len(buffs) must be <= batchSize supplied in TryUpgradeToConn().
// len(buffs) must be <= [MaximumWriteBatchSize].
//
// WriteBatchTo may return a [neterror.ErrUDPGSODisabled] error if UDP GSO
// was disabled as a result of a send error.
+3 -10
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !linux
@@ -6,18 +6,11 @@
package batching
import (
"tailscale.com/control/controlknobs"
"tailscale.com/types/nettype"
)
// TryUpgradeToConn is no-op on all platforms except linux.
func TryUpgradeToConn(pconn nettype.PacketConn, _ string, _ int) nettype.PacketConn {
func TryUpgradeToConn(pconn nettype.PacketConn, _ string, _ string, _ *controlknobs.Knobs) nettype.PacketConn {
return pconn
}
var controlMessageSize = 0
func MinControlMessageSize() int {
return controlMessageSize
}
const IdealBatchSize = 1
+416 -156
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package batching
@@ -7,6 +7,7 @@ import (
"encoding/binary"
"errors"
"fmt"
"io"
"net"
"net/netip"
"runtime"
@@ -20,10 +21,13 @@ import (
"golang.org/x/net/ipv4"
"golang.org/x/net/ipv6"
"golang.org/x/sys/unix"
"tailscale.com/control/controlknobs"
"tailscale.com/envknob"
"tailscale.com/hostinfo"
"tailscale.com/net/neterror"
"tailscale.com/net/packet"
"tailscale.com/types/nettype"
"tailscale.com/util/clientmetric"
)
// xnetBatchReaderWriter defines the batching i/o methods of
@@ -51,26 +55,29 @@ var (
// linuxBatchingConn is a UDP socket that provides batched i/o. It implements
// [Conn].
type linuxBatchingConn struct {
pc *net.UDPConn
xpc xnetBatchReaderWriter
rxOffload bool // supports UDP GRO or similar
txOffload atomic.Bool // supports UDP GSO or similar
setGSOSizeInControl func(control *[]byte, gsoSize uint16) // typically setGSOSizeInControl(); swappable for testing
getGSOSizeFromControl func(control []byte) (int, error) // typically getGSOSizeFromControl(); swappable for testing
sendBatchPool sync.Pool
pc *net.UDPConn
xpc xnetBatchReaderWriter
rxOffload bool // supports UDP GRO or similar
txOffload atomic.Bool // supports UDP GSO or similar
msgsPool sync.Pool
rxqOverflowsMetric *clientmetric.Metric
// neverGSOEqualTail, when non-nil and true, enables a sentinel-tail
// workaround in the UDP GSO TX path. It points at a
// [controlknobs.Knobs.NeverGSOEqualTail] field so the value can be
// toggled live via the control plane without requiring a socket rebind.
// It is read once per write at the top of [linuxBatchingConn.WriteBatchTo].
neverGSOEqualTail *atomic.Bool
// readOpMu guards read operations that must perform accounting against
// rxqOverflows in single-threaded fashion. There are no concurrent usages
// of read operations at the time of writing (2026-03-09), but it would be
// unidiomatic to push this responsibility onto callers.
readOpMu sync.Mutex
rxqOverflows uint32 // kernel pumps a cumulative counter, which we track to push a clientmetric delta value
}
func (c *linuxBatchingConn) ReadFromUDPAddrPort(p []byte) (n int, addr netip.AddrPort, err error) {
if c.rxOffload {
// UDP_GRO is opt-in on Linux via setsockopt(). Once enabled you may
// receive a "monster datagram" from any read call. The ReadFrom() API
// does not support passing the GSO size and is unsafe to use in such a
// case. Other platforms may vary in behavior, but we go with the most
// conservative approach to prevent this from becoming a footgun in the
// future.
return 0, netip.AddrPort{}, errors.New("rx UDP offload is enabled on this socket, single packet reads are unavailable")
}
return c.pc.ReadFromUDPAddrPort(p)
return 0, netip.AddrPort{}, errors.New("single packet reads are unsupported")
}
func (c *linuxBatchingConn) SetDeadline(t time.Time) error {
@@ -89,6 +96,15 @@ const (
// This was initially established for Linux, but may split out to
// GOOS-specific values later. It originates as UDP_MAX_SEGMENTS in the
// kernel's TX path, and UDP_GRO_CNT_MAX for RX.
//
// As long as we use one fragment per datagram, this also serves as a
// limit for the number of fragments we can coalesce during scatter-gather writes.
//
// 64 is below the 1024 of IOV_MAX (Linux) or UIO_MAXIOV (BSD),
// and the 256 of WSABUF_MAX_COUNT (Windows).
//
// (2026-04) If we begin shipping datagrams in more than one fragment,
// an independent fragment count limit needs to be implemented.
udpSegmentMaxDatagrams = 64
)
@@ -98,24 +114,63 @@ const (
maxIPv6PayloadLen = 1<<16 - 1 - 8
)
// neverGSOEqualTailSentinelPayload is appended to UDP GSO packet batches under
// certain conditions in order to workaround Linux kernel UDP GSO bugs. In the
// case of magicsock, 0x07 is handled as WireGuard, and wireguard-go silently
// drops the packet as it's less than [device.MinMessageSize].
var neverGSOEqualTailSentinelPayload = []byte{0x07}
// coalesceMessages iterates 'buffs', setting and coalescing them in 'msgs'
// where possible while maintaining datagram order.
//
// It aggregates message components as a list of buffers without copying,
// and expects to be used only on Linux with scatter-gather writes via sendmmsg(2).
//
// All msgs[i].Buffers len must be one. Will panic if there is not enough msgs
// to coalesce all buffs.
//
// All msgs have their Addr field set to addr.
//
// All msgs[i].Buffers[0] are preceded by a Geneve header (geneve) if geneve.VNI.IsSet().
func (c *linuxBatchingConn) coalesceMessages(addr *net.UDPAddr, geneve packet.GeneveHeader, buffs [][]byte, msgs []ipv6.Message, offset int) int {
//
// neverGSOEqualTail, when true, enables the sentinel-tail workaround. It is
// loaded by the caller and passed in so a single coalesceMessages call sees a
// consistent value even if the underlying control knob flips concurrently.
//
// TODO(illotum) explore MSG_ZEROCOPY for large writes (>10KB).
func (c *linuxBatchingConn) coalesceMessages(addr *net.UDPAddr, geneve packet.GeneveHeader, buffs [][]byte, msgs []ipv6.Message, offset int, neverGSOEqualTail bool) int {
var (
base = -1 // index of msg we are currently coalescing into
gsoSize int // segmentation size of msgs[base]
dgramCnt int // number of dgrams coalesced into msgs[base]
endBatch bool // tracking flag to start a new batch on next iteration of buffs
base = -1 // index of msg we are currently coalescing into
gsoSize int // segmentation size of msgs[base]
dgramCnt int // number of dgrams coalesced into msgs[base]
endBatchDueToSmallerTail bool // tracking flag to start a new batch on next iteration of buffs
coalescedLen int // bytes coalesced into msgs[base]
)
maxPayloadLen := maxIPv4PayloadLen
if addr.IP.To4() == nil {
maxPayloadLen = maxIPv6PayloadLen
}
maxDatagramsPerGSOBatch := udpSegmentMaxDatagrams
if neverGSOEqualTail {
// If neverGSOEqualTail is set we might end up appending a sentinel 1-byte
// payload, so we must leave space in our accounting.
maxDatagramsPerGSOBatch -= 1
maxPayloadLen -= len(neverGSOEqualTailSentinelPayload)
}
vniIsSet := geneve.VNI.IsSet()
maybeAppendSentinelTail := func() {
if !neverGSOEqualTail || endBatchDueToSmallerTail {
// If neverGSOEqualTail is unset we should never append a sentinel
// payload as we are running on an unaffected kernel. Or, if we
// already have a smaller-than-GSO sized tail, there is no need, since
// the kernel bug we are avoiding only triggers when all fragments
// are equal in length.
return
}
msgs[base].Buffers = append(msgs[base].Buffers, neverGSOEqualTailSentinelPayload)
}
for i, buff := range buffs {
if vniIsSet {
geneve.Encode(buff)
@@ -124,91 +179,148 @@ func (c *linuxBatchingConn) coalesceMessages(addr *net.UDPAddr, geneve packet.Ge
}
if i > 0 {
msgLen := len(buff)
baseLenBefore := len(msgs[base].Buffers[0])
freeBaseCap := cap(msgs[base].Buffers[0]) - baseLenBefore
if msgLen+baseLenBefore <= maxPayloadLen &&
// okToCoalesceWithSentinel ensures we never coalesce if a sentinel
// 1-byte payload might be required, but gsoSize (or more specifically
// UDP payload length) is also 1. The whole point of appending a sentinel
// 1-byte payload is to append a smaller-than-GSO tail.
//
// This is defensive as a 1-byte payload, at the time of writing
// (2026-05-28), is unlikely to occur. The smallest WireGuard
// message size is 32 bytes ([device.MinMessageSize]), and the
// [disco.Message] header is 62 bytes.
//
// It's also overly conservative as it checks for msgLen == 1, but a
// msgLen of 1 on the tail where gsoSize is greater would also be fine.
okToCoalesceWithSentinel := !neverGSOEqualTail || msgLen > len(neverGSOEqualTailSentinelPayload)
if msgLen+coalescedLen <= maxPayloadLen &&
msgLen <= gsoSize &&
msgLen <= freeBaseCap &&
dgramCnt < udpSegmentMaxDatagrams &&
!endBatch {
msgs[base].Buffers[0] = append(msgs[base].Buffers[0], make([]byte, msgLen)...)
copy(msgs[base].Buffers[0][baseLenBefore:], buff)
if i == len(buffs)-1 {
c.setGSOSizeInControl(&msgs[base].OOB, uint16(gsoSize))
}
dgramCnt < maxDatagramsPerGSOBatch &&
!endBatchDueToSmallerTail &&
okToCoalesceWithSentinel {
// msgs[base].Buffers[0] is set to buff[i] when a new base is set.
// This appends a struct iovec element in the underlying struct msghdr (scatter-gather).
msgs[base].Buffers = append(msgs[base].Buffers, buff)
dgramCnt++
coalescedLen += msgLen
if msgLen < gsoSize {
// A smaller than gsoSize packet on the tail is legal, but
// it must end the batch.
endBatch = true
endBatchDueToSmallerTail = true
}
if i == len(buffs)-1 {
maybeAppendSentinelTail()
setGSOSizeInControl(&msgs[base].OOB, uint16(gsoSize))
}
continue
}
}
if dgramCnt > 1 {
c.setGSOSizeInControl(&msgs[base].OOB, uint16(gsoSize))
maybeAppendSentinelTail()
setGSOSizeInControl(&msgs[base].OOB, uint16(gsoSize))
}
// Reset prior to incrementing base since we are preparing to start a
// new potential batch.
endBatch = false
endBatchDueToSmallerTail = false
base++
gsoSize = len(buff)
msgs[base].OOB = msgs[base].OOB[:0]
msgs[base].Buffers[0] = buff
msgs[base].Addr = addr
dgramCnt = 1
coalescedLen = len(buff)
}
return base + 1
}
type sendBatch struct {
msgs []ipv6.Message
ua *net.UDPAddr
// msgsBatch contains recyclable (via [linuxBatchingConn.msgsPool] elements
// used across [linuxBatchingConn.ReadBatch] & [linuxBatchingConn.WriteBatchTo].
type msgsBatch struct {
msgs []ipv6.Message
writeBatchToUDPAddr *net.UDPAddr
}
func (c *linuxBatchingConn) getSendBatch() *sendBatch {
batch := c.sendBatchPool.Get().(*sendBatch)
func (c *linuxBatchingConn) getMsgsBatch() *msgsBatch {
batch := c.msgsPool.Get().(*msgsBatch)
return batch
}
func (c *linuxBatchingConn) putSendBatch(batch *sendBatch) {
for i := range batch.msgs {
batch.msgs[i] = ipv6.Message{Buffers: batch.msgs[i].Buffers, OOB: batch.msgs[i].OOB}
// putMsgsBatch resets each message in batch.msgs[:usedMsgs] for reuse, then
// returns batch to its [sync.Pool].
func (c *linuxBatchingConn) putMsgsBatch(batch *msgsBatch, usedMsgs int) {
// Clear references to previously used packet buffers, otherwise they
// may never be GC'd.
for i := range batch.msgs[:usedMsgs] {
clear((batch.msgs)[i].Buffers)
// Non-coalesced write paths access only batch.msgs[i].Buffers[0],
// but we append more during [linuxBatchingConn.coalesceMessages].
// Leave index zero accessible:
batch.msgs[i] = ipv6.Message{Buffers: batch.msgs[i].Buffers[:1], OOB: batch.msgs[i].OOB[:0]}
}
c.sendBatchPool.Put(batch)
c.msgsPool.Put(batch)
}
// appendSentinelTailBatchSizeThreshold represents the minimum batch size
// required to enter [linuxBatchingConn.coalesceMessages] when
// [linuxBatchingConn.neverGSOEqualTail] is set. If the batch of packets is less
// than this value, and neverGSOEqualTail is set, we avoid UDP GSO altogether.
// Appending a sentinel packet, regardless of size, is still overhead on sender,
// middle network, and receiver.
//
// Coalescing (UDP GSO) greatly improves performance for sender (and receiver if
// they support UDP GRO), but there are diminishing returns if batches are small.
// We attempt to balance these diminishing returns against the introduction of
// dead-weight sentinel packets.
//
// The initial value of 8 is a power of 2, and in the worst case leads to 6%
// payload overhead if the batch is made up of minimum-sized WireGuard transport
// messages (empty payload keepalives). Worst case is unlikely.
//
// 8 * (20 bytes IPv4 header + 8 byte UDP header + 32 byte WG message) = 480 bytes
// sentinel tail is 20 byte IPv4 header + 8 byte UDP header + 1 byte payload = 29 bytes
// 29/480 = 0.060...
const appendSentinelTailBatchSizeThreshold = 8
func (c *linuxBatchingConn) WriteBatchTo(buffs [][]byte, addr netip.AddrPort, geneve packet.GeneveHeader, offset int) error {
batch := c.getSendBatch()
defer c.putSendBatch(batch)
batch := c.getMsgsBatch()
usedMsgs := len(buffs)
defer c.putMsgsBatch(batch, usedMsgs)
if addr.Addr().Is6() {
as16 := addr.Addr().As16()
copy(batch.ua.IP, as16[:])
batch.ua.IP = batch.ua.IP[:16]
copy(batch.writeBatchToUDPAddr.IP, as16[:])
batch.writeBatchToUDPAddr.IP = batch.writeBatchToUDPAddr.IP[:16]
} else {
as4 := addr.Addr().As4()
copy(batch.ua.IP, as4[:])
batch.ua.IP = batch.ua.IP[:4]
copy(batch.writeBatchToUDPAddr.IP, as4[:])
batch.writeBatchToUDPAddr.IP = batch.writeBatchToUDPAddr.IP[:4]
}
batch.ua.Port = int(addr.Port())
batch.writeBatchToUDPAddr.Port = int(addr.Port())
batch.writeBatchToUDPAddr.Zone = addr.Addr().Zone()
// Load the control knob once per write so a single call sees a consistent
// value even if the knob flips concurrently.
neverGSOEqualTail := c.neverGSOEqualTail != nil && c.neverGSOEqualTail.Load()
var (
n int
retried bool
)
retry:
if c.txOffload.Load() {
n = c.coalesceMessages(batch.ua, geneve, buffs, batch.msgs, offset)
if c.txOffload.Load() && (!neverGSOEqualTail || len(buffs) >= appendSentinelTailBatchSizeThreshold) {
n = c.coalesceMessages(batch.writeBatchToUDPAddr, geneve, buffs, batch.msgs, offset, neverGSOEqualTail)
} else {
mutableOffset := offset // don't mutate offset across retries
vniIsSet := geneve.VNI.IsSet()
if vniIsSet {
offset -= packet.GeneveFixedHeaderLength
mutableOffset -= packet.GeneveFixedHeaderLength
}
for i := range buffs {
if vniIsSet {
geneve.Encode(buffs[i])
}
batch.msgs[i].Buffers[0] = buffs[i][offset:]
batch.msgs[i].Addr = batch.ua
batch.msgs[i].Buffers[0] = buffs[i][mutableOffset:]
// Buffers length may be > 1 (scatter-gather) if we passed through
// coalesceMessages during a first pass, and landed here as part of
// goto retry.
batch.msgs[i].Buffers = batch.msgs[i].Buffers[:1]
batch.msgs[i].Addr = batch.writeBatchToUDPAddr
batch.msgs[i].OOB = batch.msgs[i].OOB[:0]
}
n = len(buffs)
@@ -245,68 +357,166 @@ func (c *linuxBatchingConn) writeBatch(msgs []ipv6.Message) error {
}
}
// splitCoalescedMessages splits coalesced messages from the tail of dst
// beginning at index 'firstMsgAt' into the head of the same slice. It reports
// the number of elements to evaluate in msgs for nonzero len (msgs[i].N). An
// error is returned if a socket control message cannot be parsed or a split
// operation would overflow msgs.
func (c *linuxBatchingConn) splitCoalescedMessages(msgs []ipv6.Message, firstMsgAt int) (n int, err error) {
for i := firstMsgAt; i < len(msgs); i++ {
msg := &msgs[i]
if msg.N == 0 {
return n, err
// getDataFromControl returns the data portion of the first control msg with
// matching cmsgLevel, matching cmsgType, and min data len of minDataLen, in
// control. If no matching cmsg is found or the len(control) < unix.SizeofCmsghdr,
// this function returns nil data. A non-nil error will be returned if
// len(control) > unix.SizeofCmsghdr but its contents cannot be parsed as a
// socket control message.
func getDataFromControl(control []byte, cmsgLevel, cmsgType int32, minDataLen int) ([]byte, error) {
var (
hdr unix.Cmsghdr
data []byte
rem = control
err error
)
for len(rem) > unix.SizeofCmsghdr {
hdr, data, rem, err = unix.ParseOneSocketControlMessage(rem)
if err != nil {
return nil, fmt.Errorf("error parsing socket control message: %w", err)
}
if hdr.Level == cmsgLevel && hdr.Type == cmsgType && len(data) >= minDataLen {
return data, nil
}
}
return nil, nil
}
// getRXQOverflowsFromControl returns the rxq overflows cumulative counter found
// in control. If no rxq counter is found or the len(control) < unix.SizeofCmsghdr,
// this function returns 0. A non-nil error will be returned if control is
// malformed.
func getRXQOverflowsFromControl(control []byte) (uint32, error) {
data, err := getDataFromControl(control, unix.SOL_SOCKET, unix.SO_RXQ_OVFL, 4)
if err != nil {
return 0, err
}
if len(data) >= 4 {
return binary.NativeEndian.Uint32(data), nil
}
return 0, nil
}
// handleRXQOverflowCounter handles any rx queue overflow counter contained in
// the tail of msgs.
func (c *linuxBatchingConn) handleRXQOverflowCounter(msgs []ipv6.Message, rxErr error) {
if len(msgs) == 0 || rxErr != nil || c.rxqOverflowsMetric == nil {
return
}
tailMsg := msgs[len(msgs)-1] // we only care about the latest value as it's a cumulative counter
if tailMsg.NN == 0 {
return
}
rxqOverflows, err := getRXQOverflowsFromControl(tailMsg.OOB[:tailMsg.NN])
if err != nil {
return
}
// The counter is always present once nonzero on the kernel side. Compare it
// with our previous view, push the delta to the clientmetric, and update
// our view.
if rxqOverflows == c.rxqOverflows {
return
}
delta := int64(rxqOverflows - c.rxqOverflows)
c.rxqOverflowsMetric.Add(delta)
c.rxqOverflows = rxqOverflows
}
const (
// groEnabledSlotSize is the size to supply per recvmmsg() slot when GRO is
// enabled. If we supply less than this, coalesced datagrams may be silently
// truncated by the kernel.
groEnabledSlotSize = 1<<16 - 1
// groDisabledSlotSize is the size to supply per recvmmsg() slot when GRO is
// disabled. It's ~twice the size of max Ethernet MTU, but smaller than
// [ReadSlabMultiple]. Without GRO we might as well read more datagrams per
// syscall, and it's reasonable to expect datagrams to be no larger than this
// size.
//
// TODO: Reconsider divisor. Older kernels without UDP GRO support may
// benefit from more slots per recvmmsg syscall. Run benchmarks to inform.
groDisabledSlotSize = groEnabledSlotSize / 4
)
func (c *linuxBatchingConn) ReadBatch(slab []byte, packets []ReceivedPacket) (n int, err error) {
if len(slab) < ReadSlabMultiple {
return 0, fmt.Errorf("len(slab): %d < ReadSlabMultiple(%d)", len(slab), ReadSlabMultiple)
}
if len(packets) < MinimumReadBatchSize {
return 0, fmt.Errorf("len(packets): %d < MinimumReadBatchSize(%d)", len(packets), MinimumReadBatchSize)
}
c.readOpMu.Lock()
defer c.readOpMu.Unlock()
batch := c.getMsgsBatch()
recvmmsgSlotSize := groEnabledSlotSize
maxMsgsPerRecvmmsgSlot := udpGROCountMax
if !c.rxOffload {
recvmmsgSlotSize = groDisabledSlotSize
maxMsgsPerRecvmmsgSlot = 1
}
maxMsgCount := min(
len(slab)/recvmmsgSlotSize, // how many slots fit in slab
len(packets)/maxMsgsPerRecvmmsgSlot, // how many datagrams fit in packets
len(batch.msgs), // how many [ipv6.Message] we have
)
defer c.putMsgsBatch(batch, maxMsgCount)
rem := slab
for i := range maxMsgCount {
end := recvmmsgSlotSize
if len(rem) < recvmmsgSlotSize {
end = len(rem)
}
batch.msgs[i].Buffers[0] = rem[:end]
batch.msgs[i].OOB = batch.msgs[i].OOB[:cap(batch.msgs[i].OOB)]
rem = rem[end:]
}
n, err = c.xpc.ReadBatch(batch.msgs[:maxMsgCount], 0)
if err != nil {
return 0, err
}
c.handleRXQOverflowCounter(batch.msgs[:n], err)
return fillReceivedPackets(batch.msgs[:n], recvmmsgSlotSize, packets, c.rxOffload)
}
func fillReceivedPackets(msgs []ipv6.Message, slabOffset int, packets []ReceivedPacket, rxOffload bool) (n int, err error) {
for i, msg := range msgs {
var (
gsoSize int
start int
end = msg.N
numToSplit = 1
)
gsoSize, err = c.getGSOSizeFromControl(msg.OOB[:msg.NN])
if err != nil {
return n, err
}
if gsoSize > 0 {
numToSplit = (msg.N + gsoSize - 1) / gsoSize
end = gsoSize
}
for j := 0; j < numToSplit; j++ {
if n > i {
return n, errors.New("splitting coalesced packet resulted in overflow")
if rxOffload {
gsoSize, err = getGSOSizeFromControl(msg.OOB[:msg.NN])
if err != nil {
return n, err
}
copied := copy(msgs[n].Buffers[0], msg.Buffers[0][start:end])
msgs[n].N = copied
msgs[n].Addr = msg.Addr
start = end
end += gsoSize
if end > msg.N {
end = msg.N
if gsoSize > 0 {
numToSplit = (msg.N + gsoSize - 1) / gsoSize
}
}
addrPort := msg.Addr.(*net.UDPAddr).AddrPort()
regionOffset := i * slabOffset // region may contain multiple coalesced packets
for j := 0; j < numToSplit; j++ {
if n >= len(packets) {
return n, fmt.Errorf("%w: filling received packet metadata resulted in overflow", io.ErrShortBuffer)
}
end := msg.N
if gsoSize > 0 && start+gsoSize < end {
end = start + gsoSize
}
packets[n] = ReceivedPacket{
Offset: regionOffset + start,
Size: end - start,
Source: addrPort,
}
n++
}
if i != n-1 {
// It is legal for bytes to move within msg.Buffers[0] as a result
// of splitting, so we only zero the source msg len when it is not
// the destination of the last split operation above.
msg.N = 0
start = end
}
}
return n, nil
}
func (c *linuxBatchingConn) ReadBatch(msgs []ipv6.Message, flags int) (n int, err error) {
if !c.rxOffload || len(msgs) < 2 {
return c.xpc.ReadBatch(msgs, flags)
}
// Read into the tail of msgs, split into the head.
readAt := len(msgs) - 2
numRead, err := c.xpc.ReadBatch(msgs[readAt:], 0)
if err != nil || numRead == 0 {
return 0, err
}
return c.splitCoalescedMessages(msgs, readAt)
}
func (c *linuxBatchingConn) LocalAddr() net.Addr {
return c.pc.LocalAddr().(*net.UDPAddr)
}
@@ -319,19 +529,45 @@ func (c *linuxBatchingConn) Close() error {
return c.pc.Close()
}
// tryEnableRXQOverflowsCounter attempts to enable the SO_RXQ_OVFL socket option
// on pconn, and returns the result. SO_RXQ_OVFL was added in Linux v2.6.33.
func tryEnableRXQOverflowsCounter(pconn nettype.PacketConn) (enabled bool) {
if c, ok := pconn.(*net.UDPConn); ok {
rc, err := c.SyscallConn()
if err != nil {
return
}
rc.Control(func(fd uintptr) {
enabled = syscall.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_RXQ_OVFL, 1) == nil
})
}
return enabled
}
// tryEnableUDPOffload attempts to enable the UDP_GRO socket option on pconn,
// and returns two booleans indicating TX and RX UDP offload support.
func tryEnableUDPOffload(pconn nettype.PacketConn) (hasTX bool, hasRX bool) {
// and returns two booleans indicating TX and RX UDP offload support. If knobs
// is non-nil, UDP GSO and/or UDP GRO may be disabled via control-plane node
// attributes.
func tryEnableUDPOffload(pconn nettype.PacketConn, knobs *controlknobs.Knobs) (hasTX bool, hasRX bool) {
disableGSO := envknob.Bool("TS_DEBUG_DISABLE_UDP_GSO") ||
(knobs != nil && knobs.DisableUDPGSO.Load())
disableGRO := envknob.Bool("TS_DEBUG_DISABLE_UDP_GRO") ||
(knobs != nil && knobs.DisableUDPGRO.Load())
if c, ok := pconn.(*net.UDPConn); ok {
rc, err := c.SyscallConn()
if err != nil {
return
}
err = rc.Control(func(fd uintptr) {
_, errSyscall := syscall.GetsockoptInt(int(fd), unix.IPPROTO_UDP, unix.UDP_SEGMENT)
hasTX = errSyscall == nil
errSyscall = syscall.SetsockoptInt(int(fd), unix.IPPROTO_UDP, unix.UDP_GRO, 1)
hasRX = errSyscall == nil
var errSyscall error
if !disableGSO {
_, errSyscall = syscall.GetsockoptInt(int(fd), unix.IPPROTO_UDP, unix.UDP_SEGMENT)
hasTX = errSyscall == nil
}
if !disableGRO {
errSyscall = syscall.SetsockoptInt(int(fd), unix.IPPROTO_UDP, unix.UDP_GRO, 1)
hasRX = errSyscall == nil
}
})
if err != nil {
return false, false
@@ -340,32 +576,25 @@ func tryEnableUDPOffload(pconn nettype.PacketConn) (hasTX bool, hasRX bool) {
return hasTX, hasRX
}
// getGSOSizeFromControl returns the GSO size found in control. If no GSO size
// is found or the len(control) < unix.SizeofCmsghdr, this function returns 0.
// A non-nil error will be returned if len(control) > unix.SizeofCmsghdr but
// its contents cannot be parsed as a socket control message.
// getGSOSizeFromControl returns the GSO size found in control associated with a
// cmsg type of UDP_GRO, which the kernel populates in the read direction. If no
// GSO size is found or the len(control) < unix.SizeofCmsghdr, this function
// returns 0. A non-nil error will be returned if control is malformed.
func getGSOSizeFromControl(control []byte) (int, error) {
var (
hdr unix.Cmsghdr
data []byte
rem = control
err error
)
for len(rem) > unix.SizeofCmsghdr {
hdr, data, rem, err = unix.ParseOneSocketControlMessage(rem)
if err != nil {
return 0, fmt.Errorf("error parsing socket control message: %w", err)
}
if hdr.Level == unix.SOL_UDP && hdr.Type == unix.UDP_GRO && len(data) >= 2 {
return int(binary.NativeEndian.Uint16(data[:2])), nil
}
data, err := getDataFromControl(control, unix.SOL_UDP, unix.UDP_GRO, 2)
if err != nil {
return 0, err
}
if len(data) >= 2 {
return int(binary.NativeEndian.Uint16(data)), nil
}
return 0, nil
}
// setGSOSizeInControl sets a socket control message in control containing
// gsoSize. If len(control) < controlMessageSize control's len will be set to 0.
// gsoSize with an associated cmsg type of UDP_SEGMENT, which we are responsible
// for populating prior to writing towards the kernel. If len(control) < controlMessageSize
// control's len will be set to 0.
func setGSOSizeInControl(control *[]byte, gsoSize uint16) {
*control = (*control)[:0]
if cap(*control) < int(unsafe.Sizeof(unix.Cmsghdr{})) {
@@ -383,10 +612,39 @@ func setGSOSizeInControl(control *[]byte, gsoSize uint16) {
*control = (*control)[:unix.CmsgSpace(2)]
}
var (
rxqOverflowsMetricsMu sync.Mutex
rxqOverflowsMetricsByName map[string]*clientmetric.Metric
)
// getRXQOverflowsMetric returns a counter-based [*clientmetric.Metric] for the
// provided name in a thread-safe manner. Callers may pass the same metric name
// multiple times, which is common across rebinds of the underlying, associated
// [Conn].
func getRXQOverflowsMetric(name string) *clientmetric.Metric {
if len(name) == 0 {
return nil
}
rxqOverflowsMetricsMu.Lock()
defer rxqOverflowsMetricsMu.Unlock()
m, ok := rxqOverflowsMetricsByName[name]
if ok {
return m
}
if rxqOverflowsMetricsByName == nil {
rxqOverflowsMetricsByName = make(map[string]*clientmetric.Metric)
}
m = clientmetric.NewCounter(name)
rxqOverflowsMetricsByName[name] = m
return m
}
// TryUpgradeToConn probes the capabilities of the OS and pconn, and upgrades
// pconn to a [Conn] if appropriate. A batch size of [IdealBatchSize] is
// suggested for the best performance.
func TryUpgradeToConn(pconn nettype.PacketConn, network string, batchSize int) nettype.PacketConn {
// pconn to a [Conn] if appropriate. If len(rxqOverflowsMetricName) is
// nonzero, then read ops will propagate the SO_RXQ_OVFL control message counter
// to a clientmetric with the supplied name. If knobs is non-nil, UDP GSO
// and/or UDP GRO may be disabled via control-plane node attributes.
func TryUpgradeToConn(pconn nettype.PacketConn, network string, rxqOverflowsMetricName string, knobs *controlknobs.Knobs) nettype.PacketConn {
if runtime.GOOS != "linux" {
// Exclude Android.
return pconn
@@ -394,7 +652,8 @@ func TryUpgradeToConn(pconn nettype.PacketConn, network string, batchSize int) n
if network != "udp4" && network != "udp6" {
return pconn
}
if strings.HasPrefix(hostinfo.GetOSVersion(), "2.") {
osVer := hostinfo.GetOSVersion()
if strings.HasPrefix(osVer, "2.") {
// recvmmsg/sendmmsg were added in 2.6.33, but we support down to
// 2.6.32 for old NAS devices. See https://github.com/tailscale/tailscale/issues/6807.
// As a cheap heuristic: if the Linux kernel starts with "2", just
@@ -408,23 +667,21 @@ func TryUpgradeToConn(pconn nettype.PacketConn, network string, batchSize int) n
return pconn
}
b := &linuxBatchingConn{
pc: uc,
getGSOSizeFromControl: getGSOSizeFromControl,
setGSOSizeInControl: setGSOSizeInControl,
sendBatchPool: sync.Pool{
pc: uc,
msgsPool: sync.Pool{
New: func() any {
ua := &net.UDPAddr{
IP: make([]byte, 16),
}
msgs := make([]ipv6.Message, batchSize)
msgs := make([]ipv6.Message, MaximumWriteBatchSize)
for i := range msgs {
msgs[i].Buffers = make([][]byte, 1)
msgs[i].Addr = ua
msgs[i].OOB = make([]byte, controlMessageSize)
}
return &sendBatch{
ua: ua,
msgs: msgs,
return &msgsBatch{
writeBatchToUDPAddr: ua,
msgs: msgs,
}
},
},
@@ -438,23 +695,26 @@ func TryUpgradeToConn(pconn nettype.PacketConn, network string, batchSize int) n
panic("bogus network")
}
var txOffload bool
txOffload, b.rxOffload = tryEnableUDPOffload(uc)
txOffload, b.rxOffload = tryEnableUDPOffload(uc, knobs)
b.txOffload.Store(txOffload)
if knobs != nil {
b.neverGSOEqualTail = &knobs.NeverGSOEqualTail
}
if len(rxqOverflowsMetricName) > 0 && tryEnableRXQOverflowsCounter(uc) {
// Don't register the metric unless the socket option has been
// successfully set, otherwise we will report a misleading zero value
// counter on the wire. This is one reason why we prefer to handle
// clientmetric instantiation internally, vs letting callers pass them
// to TryUpgradeToConn.
b.rxqOverflowsMetric = getRXQOverflowsMetric(rxqOverflowsMetricName)
}
return b
}
var controlMessageSize = -1 // bomb if used for allocation before init
func init() {
// controlMessageSize is set to hold a UDP_GRO or UDP_SEGMENT control
// message. These contain a single uint16 of data.
controlMessageSize = unix.CmsgSpace(2)
controlMessageSize =
unix.CmsgSpace(2) + // UDP_GRO or UDP_SEGMENT gsoSize (uint16)
unix.CmsgSpace(4) // SO_RXQ_OVFL counter (uint32)
}
// MinControlMessageSize returns the minimum control message size required to
// support read batching via [Conn.ReadBatch].
func MinControlMessageSize() int {
return controlMessageSize
}
const IdealBatchSize = 128
-248
View File
@@ -1,248 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package captivedetection provides a way to detect if the system is connected to a network that has
// a captive portal. It does this by making HTTP requests to known captive portal detection endpoints
// and checking if the HTTP responses indicate that a captive portal might be present.
package captivedetection
import (
"context"
"net"
"net/http"
"runtime"
"strconv"
"strings"
"sync"
"syscall"
"time"
"tailscale.com/net/netmon"
"tailscale.com/syncs"
"tailscale.com/tailcfg"
"tailscale.com/types/logger"
)
// Detector checks whether the system is behind a captive portal.
type Detector struct {
clock func() time.Time
// httpClient is the HTTP client that is used for captive portal detection. It is configured
// to not follow redirects, have a short timeout and no keep-alive.
httpClient *http.Client
// currIfIndex is the index of the interface that is currently being used by the httpClient.
currIfIndex int
// mu guards currIfIndex.
mu syncs.Mutex
// logf is the logger used for logging messages. If it is nil, log.Printf is used.
logf logger.Logf
}
// NewDetector creates a new Detector instance for captive portal detection.
func NewDetector(logf logger.Logf) *Detector {
d := &Detector{logf: logf}
d.httpClient = &http.Client{
// No redirects allowed
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
Transport: &http.Transport{
DialContext: d.dialContext,
DisableKeepAlives: true,
},
Timeout: Timeout,
}
return d
}
func (d *Detector) Now() time.Time {
if d.clock != nil {
return d.clock()
}
return time.Now()
}
// Timeout is the timeout for captive portal detection requests. Because the captive portal intercepting our requests
// is usually located on the LAN, this is a relatively short timeout.
const Timeout = 3 * time.Second
// Detect is the entry point to the API. It attempts to detect if the system is behind a captive portal
// by making HTTP requests to known captive portal detection Endpoints. If any of the requests return a response code
// or body that looks like a captive portal, Detect returns true. It returns false in all other cases, including when any
// error occurs during a detection attempt.
//
// This function might take a while to return, as it will attempt to detect a captive portal on all available interfaces
// by performing multiple HTTP requests. It should be called in a separate goroutine if you want to avoid blocking.
func (d *Detector) Detect(ctx context.Context, netMon *netmon.Monitor, derpMap *tailcfg.DERPMap, preferredDERPRegionID int) (found bool) {
return d.detectCaptivePortalWithGOOS(ctx, netMon, derpMap, preferredDERPRegionID, runtime.GOOS)
}
func (d *Detector) detectCaptivePortalWithGOOS(ctx context.Context, netMon *netmon.Monitor, derpMap *tailcfg.DERPMap, preferredDERPRegionID int, goos string) (found bool) {
ifState := netMon.InterfaceState()
if !ifState.AnyInterfaceUp() {
d.logf("[v2] DetectCaptivePortal: no interfaces up, returning false")
return false
}
endpoints := availableEndpoints(derpMap, preferredDERPRegionID, d.logf, goos)
// Here we try detecting a captive portal using *all* available interfaces on the system
// that have a IPv4 address. We consider to have found a captive portal when any interface
// reports one may exists. This is necessary because most systems have multiple interfaces,
// and most importantly on macOS no default route interface is set until the user has accepted
// the captive portal alert thrown by the system. If no default route interface is known,
// we need to try with anything that might remotely resemble a Wi-Fi interface.
for ifName, i := range ifState.Interface {
if !i.IsUp() || i.IsLoopback() || interfaceNameDoesNotNeedCaptiveDetection(ifName, goos) {
continue
}
addrs, err := i.Addrs()
if err != nil {
d.logf("[v1] DetectCaptivePortal: failed to get addresses for interface %s: %v", ifName, err)
continue
}
if len(addrs) == 0 {
continue
}
d.logf("[v2] attempting to do captive portal detection on interface %s", ifName)
res := d.detectOnInterface(ctx, i.Index, endpoints)
if res {
d.logf("DetectCaptivePortal(found=true,ifName=%s)", ifName)
return true
}
}
d.logf("DetectCaptivePortal(found=false)")
return false
}
// interfaceNameDoesNotNeedCaptiveDetection returns true if an interface does not require captive portal detection
// based on its name. This is useful to avoid making unnecessary HTTP requests on interfaces that are known to not
// require it. We also avoid making requests on the interface prefixes "pdp" and "rmnet", which are cellular data
// interfaces on iOS and Android, respectively, and would be needlessly battery-draining.
func interfaceNameDoesNotNeedCaptiveDetection(ifName string, goos string) bool {
ifName = strings.ToLower(ifName)
excludedPrefixes := []string{"tailscale", "tun", "tap", "docker", "kube", "wg", "ipsec"}
if goos == "windows" {
excludedPrefixes = append(excludedPrefixes, "loopback", "tunnel", "ppp", "isatap", "teredo", "6to4")
} else if goos == "darwin" || goos == "ios" {
excludedPrefixes = append(excludedPrefixes, "pdp", "awdl", "bridge", "ap", "utun", "tap", "llw", "anpi", "lo", "stf", "gif", "xhc", "pktap")
} else if goos == "android" {
excludedPrefixes = append(excludedPrefixes, "rmnet", "p2p", "dummy", "sit")
}
for _, prefix := range excludedPrefixes {
if strings.HasPrefix(ifName, prefix) {
return true
}
}
return false
}
// detectOnInterface reports whether or not we think the system is behind a
// captive portal, detected by making a request to a URL that we know should
// return a "204 No Content" response and checking if that's what we get.
//
// The boolean return is whether we think we have a captive portal.
func (d *Detector) detectOnInterface(ctx context.Context, ifIndex int, endpoints []Endpoint) bool {
defer d.httpClient.CloseIdleConnections()
use := min(len(endpoints), 5)
endpoints = endpoints[:use]
d.logf("[v2] %d available captive portal detection endpoints; trying %v", len(endpoints), use)
// We try to detect the captive portal more quickly by making requests to multiple endpoints concurrently.
var wg sync.WaitGroup
resultCh := make(chan bool, len(endpoints))
// Once any goroutine detects a captive portal, we shut down the others.
ctx, cancel := context.WithCancel(ctx)
defer cancel()
for _, e := range endpoints {
wg.Add(1)
go func(endpoint Endpoint) {
defer wg.Done()
found, err := d.verifyCaptivePortalEndpoint(ctx, endpoint, ifIndex)
if err != nil {
if ctx.Err() == nil {
d.logf("[v1] checkCaptivePortalEndpoint failed with endpoint %v: %v", endpoint, err)
}
return
}
if found {
cancel() // one match is good enough
resultCh <- true
}
}(e)
}
go func() {
wg.Wait()
close(resultCh)
}()
for result := range resultCh {
if result {
// If any of the endpoints seems to be a captive portal, we consider the system to be behind one.
return true
}
}
return false
}
// verifyCaptivePortalEndpoint checks if the given Endpoint is a captive portal by making an HTTP request to the
// given Endpoint URL using the interface with index ifIndex, and checking if the response looks like a captive portal.
func (d *Detector) verifyCaptivePortalEndpoint(ctx context.Context, e Endpoint, ifIndex int) (found bool, err error) {
ctx, cancel := context.WithTimeout(ctx, Timeout)
defer cancel()
u := *e.URL
v := u.Query()
v.Add("t", strconv.Itoa(int(d.Now().Unix())))
u.RawQuery = v.Encode()
req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
if err != nil {
return false, err
}
req.Header.Set("Cache-Control", "no-cache, no-store, must-revalidate, no-transform, max-age=0")
// Attach the Tailscale challenge header if the endpoint supports it. Not all captive portal detection endpoints
// support this, so we only attach it if the endpoint does.
if e.SupportsTailscaleChallenge {
// Note: the set of valid characters in a challenge and the total
// length is limited; see isChallengeChar in cmd/derper for more
// details.
chal := "ts_" + e.URL.Host
req.Header.Set("X-Tailscale-Challenge", chal)
}
d.mu.Lock()
d.currIfIndex = ifIndex
d.mu.Unlock()
// Make the actual request, and check if the response looks like a captive portal or not.
r, err := d.httpClient.Do(req)
if err != nil {
return false, err
}
return e.responseLooksLikeCaptive(r, d.logf), nil
}
func (d *Detector) dialContext(ctx context.Context, network, addr string) (net.Conn, error) {
d.mu.Lock()
defer d.mu.Unlock()
ifIndex := d.currIfIndex
dl := &net.Dialer{
Timeout: Timeout,
Control: func(network, address string, c syscall.RawConn) error {
return setSocketInterfaceIndex(c, ifIndex, d.logf)
},
}
return dl.DialContext(ctx, network, addr)
}
-178
View File
@@ -1,178 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package captivedetection
import (
"cmp"
"fmt"
"io"
"net/http"
"net/url"
"slices"
"go4.org/mem"
"tailscale.com/net/dnsfallback"
"tailscale.com/tailcfg"
"tailscale.com/types/logger"
)
// EndpointProvider is an enum that represents the source of an Endpoint.
type EndpointProvider int
const (
// DERPMapPreferred is used for an endpoint that is a DERP node contained in the current preferred DERP region,
// as provided by the DERPMap.
DERPMapPreferred EndpointProvider = iota
// DERPMapOther is used for an endpoint that is a DERP node, but not contained in the current preferred DERP region.
DERPMapOther
// Tailscale is used for endpoints that are the Tailscale coordination server or admin console.
Tailscale
)
func (p EndpointProvider) String() string {
switch p {
case DERPMapPreferred:
return "DERPMapPreferred"
case Tailscale:
return "Tailscale"
case DERPMapOther:
return "DERPMapOther"
default:
return fmt.Sprintf("EndpointProvider(%d)", p)
}
}
// Endpoint represents a URL that can be used to detect a captive portal, along with the expected
// result of the HTTP request.
type Endpoint struct {
// URL is the URL that we make an HTTP request to as part of the captive portal detection process.
URL *url.URL
// StatusCode is the expected HTTP status code that we expect to see in the response.
StatusCode int
// ExpectedContent is a string that we expect to see contained in the response body. If this is non-empty,
// we will check that the response body contains this string. If it is empty, we will not check the response body
// and only check the status code.
ExpectedContent string
// SupportsTailscaleChallenge is true if the endpoint will return the sent value of the X-Tailscale-Challenge
// HTTP header in its HTTP response.
SupportsTailscaleChallenge bool
// Provider is the source of the endpoint. This is used to prioritize certain endpoints over others
// (for example, a DERP node in the preferred region should always be used first).
Provider EndpointProvider
}
func (e Endpoint) String() string {
return fmt.Sprintf("Endpoint{URL=%q, StatusCode=%d, ExpectedContent=%q, SupportsTailscaleChallenge=%v, Provider=%s}", e.URL, e.StatusCode, e.ExpectedContent, e.SupportsTailscaleChallenge, e.Provider.String())
}
func (e Endpoint) Equal(other Endpoint) bool {
return e.URL.String() == other.URL.String() &&
e.StatusCode == other.StatusCode &&
e.ExpectedContent == other.ExpectedContent &&
e.SupportsTailscaleChallenge == other.SupportsTailscaleChallenge &&
e.Provider == other.Provider
}
// availableEndpoints returns a set of Endpoints which can be used for captive portal detection by performing
// one or more HTTP requests and looking at the response. The returned Endpoints are ordered by preference,
// with the most preferred Endpoint being the first in the slice.
func availableEndpoints(derpMap *tailcfg.DERPMap, preferredDERPRegionID int, logf logger.Logf, goos string) []Endpoint {
endpoints := []Endpoint{}
if derpMap == nil || len(derpMap.Regions) == 0 {
// When the client first starts, we don't have a DERPMap in LocalBackend yet. In this case,
// we use the static DERPMap from dnsfallback.
logf("captivedetection: current DERPMap is empty, using map from dnsfallback")
derpMap = dnsfallback.GetDERPMap()
}
// Use the DERP IPs as captive portal detection endpoints. Using IPs is better than hostnames
// because they do not depend on DNS resolution.
for _, region := range derpMap.Regions {
if region.Avoid || region.NoMeasureNoHome {
continue
}
for _, node := range region.Nodes {
if node.IPv4 == "" || !node.CanPort80 {
continue
}
str := "http://" + node.IPv4 + "/generate_204"
u, err := url.Parse(str)
if err != nil {
logf("captivedetection: failed to parse DERP node URL %q: %v", str, err)
continue
}
p := DERPMapOther
if region.RegionID == preferredDERPRegionID {
p = DERPMapPreferred
}
e := Endpoint{u, http.StatusNoContent, "", true, p}
endpoints = append(endpoints, e)
}
}
// Let's also try the default Tailscale coordination server and admin console.
// These are likely to be blocked on some networks.
appendTailscaleEndpoint := func(urlString string) {
u, err := url.Parse(urlString)
if err != nil {
logf("captivedetection: failed to parse Tailscale URL %q: %v", urlString, err)
return
}
endpoints = append(endpoints, Endpoint{u, http.StatusNoContent, "", false, Tailscale})
}
appendTailscaleEndpoint("http://controlplane.tailscale.com/generate_204")
appendTailscaleEndpoint("http://login.tailscale.com/generate_204")
// Sort the endpoints by provider so that we can prioritize DERP nodes in the preferred region, followed by
// any other DERP server elsewhere, then followed by Tailscale endpoints.
slices.SortFunc(endpoints, func(x, y Endpoint) int {
return cmp.Compare(x.Provider, y.Provider)
})
return endpoints
}
// responseLooksLikeCaptive checks if the given HTTP response matches the expected response for the Endpoint.
func (e Endpoint) responseLooksLikeCaptive(r *http.Response, logf logger.Logf) bool {
defer r.Body.Close()
// Check the status code first.
if r.StatusCode != e.StatusCode {
logf("[v1] unexpected status code in captive portal response: want=%d, got=%d", e.StatusCode, r.StatusCode)
return true
}
// If the endpoint supports the Tailscale challenge header, check that the response contains the expected header.
if e.SupportsTailscaleChallenge {
expectedResponse := "response ts_" + e.URL.Host
hasResponse := r.Header.Get("X-Tailscale-Response") == expectedResponse
if !hasResponse {
// The response did not contain the expected X-Tailscale-Response header, which means we are most likely
// behind a captive portal (somebody is tampering with the response headers).
logf("captive portal check response did not contain expected X-Tailscale-Response header: want=%q, got=%q", expectedResponse, r.Header.Get("X-Tailscale-Response"))
return true
}
}
// If we don't have an expected content string, we don't need to check the response body.
if e.ExpectedContent == "" {
return false
}
// Read the response body and check if it contains the expected content.
b, err := io.ReadAll(io.LimitReader(r.Body, 4096))
if err != nil {
logf("reading captive portal check response body failed: %v", err)
return false
}
hasExpectedContent := mem.Contains(mem.B(b), mem.S(e.ExpectedContent))
if !hasExpectedContent {
// The response body did not contain the expected content, that means we are most likely behind a captive portal.
logf("[v1] captive portal check response body did not contain expected content: want=%q", e.ExpectedContent)
return true
}
// If we got here, the response looks good.
return false
}
-19
View File
@@ -1,19 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !(ios || darwin)
package captivedetection
import (
"syscall"
"tailscale.com/types/logger"
)
// setSocketInterfaceIndex sets the IP_BOUND_IF socket option on the given RawConn.
// This forces the socket to use the given interface.
func setSocketInterfaceIndex(c syscall.RawConn, ifIndex int, logf logger.Logf) error {
// No-op on non-Darwin platforms.
return nil
}
-24
View File
@@ -1,24 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build ios || darwin
package captivedetection
import (
"syscall"
"golang.org/x/sys/unix"
"tailscale.com/types/logger"
)
// setSocketInterfaceIndex sets the IP_BOUND_IF socket option on the given RawConn.
// This forces the socket to use the given interface.
func setSocketInterfaceIndex(c syscall.RawConn, ifIndex int, logf logger.Logf) error {
return c.Control((func(fd uintptr) {
err := unix.SetsockoptInt(int(fd), unix.IPPROTO_IP, unix.IP_BOUND_IF, ifIndex)
if err != nil {
logf("captivedetection: failed to set IP_BOUND_IF (ifIndex=%d): %v", ifIndex, err)
}
}))
}
+321
View File
@@ -0,0 +1,321 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package connreject provides a bounded, in-memory, per-direction
// aggregator of recent connection-rejection events observed by the
// local node.
//
// A rejection event is any evidence that a connection attempt was
// blocked or dropped: a peer's TSMP reject message, an outbound TSMP
// reject we emitted because a peer's inbound connection violated our
// ACLs, a pendopen timeout where we never saw a reply, etc.
//
// The aggregator is split by [Direction]:
// - [Outgoing]: connections initiated by this node that were rejected
// or otherwise failed, keyed on the peer's address ("who we failed
// to reach").
// - [Incoming]: connections from a peer that this node rejected,
// keyed on the peer's address ("who we blocked").
//
// Repeated observations for the same (direction, proto, peer-address,
// reason) are aggregated into a single [Event], bumping Count and Last.
// Least-recently-used eviction keeps each direction's buffer bounded.
//
// This package contains only the data types and aggregator implementation;
// wiring into the rest of the system is done by feature/connreject.
package connreject
import (
"container/list"
"net/netip"
"runtime"
"sync"
"sync/atomic"
"time"
"tailscale.com/types/ipproto"
)
// Direction describes whether a rejection applied to a connection this
// node initiated (Outgoing) or a connection a peer initiated toward us
// (Incoming).
//
// Direction is a string type so it serializes directly to human-readable
// JSON without requiring MarshalText/UnmarshalText boilerplate.
type Direction string
// Direction values.
const (
DirectionUnknown Direction = ""
Outgoing Direction = "outgoing"
Incoming Direction = "incoming"
)
// Source describes how this node learned of a rejection.
type Source string
// Source values.
const (
SourceUnknown Source = ""
// SourceTSMPRecv is a TSMP reject message received from a peer.
SourceTSMPRecv Source = "tsmp_recv"
// SourceTSMPSent is a TSMP reject message this node emitted to a peer.
SourceTSMPSent Source = "tsmp_sent"
// SourcePendOpenTimeout is a pendopen-timer expiry with no response.
SourcePendOpenTimeout Source = "pendopen_timeout"
)
// Reason is a short, stable, machine-readable tag describing why a
// connection was rejected. Reason is part of the aggregation key in
// [Aggregator].
//
// These values are part of the wire format exposed over the debug
// endpoints; do not rename or repurpose them without bumping the
// capability version.
type Reason string
// Reason values.
const (
ReasonUnknown Reason = "unknown"
ReasonACL Reason = "acl"
ReasonShields Reason = "shields"
ReasonHostIPForwarding Reason = "host-ip-forwarding"
ReasonHostFirewall Reason = "host-firewall"
ReasonAppConnectorTransitIPUnknown Reason = "app-connector-transit-ip-unknown"
ReasonNoPeer Reason = "no-peer"
ReasonPeerUnreachable Reason = "peer-unreachable"
ReasonTimeout Reason = "timeout"
)
// Event is a single aggregated rejection observation.
//
// Events are aggregated by [Aggregator] on
// (Direction, Proto, peer-address, Reason). Src and Dst carry the ports
// observed on the most recent occurrence but do not participate in the
// aggregation key.
type Event struct {
// First is when this (key) was first observed. If unset on a Note,
// the aggregator fills it with the current time.
First time.Time
// Last is the most recent observation of this (key). If unset on a
// Note, the aggregator fills it with the current time.
Last time.Time
// Count is the number of observations merged into this Event. If
// unset on a Note, the aggregator treats it as 1.
Count uint32
// Direction indicates whether the event applies to an outgoing or
// incoming connection attempt.
Direction Direction
// Proto is the transport protocol of the rejected flow.
Proto ipproto.Proto
// Src is a representative source endpoint from the most recent
// observation. For Outgoing events this is our side; for Incoming
// events this is the peer side.
Src netip.AddrPort
// Dst is a representative destination endpoint from the most recent
// observation. For Outgoing events this is the peer side; for
// Incoming events this is our side.
Dst netip.AddrPort
// Reason is the machine-readable reason tag.
Reason Reason
// Source describes how we learned about this event.
Source Source
// MaybeBroken is true for the non-terminal form of an inbound TSMP
// reject (see packet.TailscaleRejectedHeader.MaybeBroken). It is
// informational and only meaningful when Source == SourceTSMPRecv.
MaybeBroken bool
}
// peerAddr returns the peer-side address for aggregation keying, based on
// the event's direction. For Outgoing, the peer is Dst; for Incoming, Src.
func (e Event) peerAddr() netip.Addr {
switch e.Direction {
case Outgoing:
return e.Dst.Addr()
case Incoming:
return e.Src.Addr()
}
return netip.Addr{}
}
// aggKey is the aggregation key for an entry in an [Aggregator].
type aggKey struct {
dir Direction
proto ipproto.Proto
addr netip.Addr
reason Reason
}
// entry is an internal aggregator entry.
type entry struct {
k aggKey
e Event
}
// dirBuf is an internal LRU+map for a single direction.
type dirBuf struct {
m map[aggKey]*list.Element // elements hold *entry
order *list.List // oldest (LRU) at Front, newest at Back
}
func newDirBuf() dirBuf {
return dirBuf{
m: make(map[aggKey]*list.Element),
order: list.New(),
}
}
// Aggregator owns bounded, per-direction LRU buffers for rejection
// [Event]s and a runtime enable flag. It is safe for concurrent use.
//
// An Aggregator is per-[LocalBackend] (or per-test); there is no
// package-level state.
type Aggregator struct {
max int // per-direction LRU capacity; <= 0 disables recording.
enabled atomic.Bool
mu sync.Mutex
outgoing dirBuf
incoming dirBuf
}
// NewAggregator returns an [Aggregator] with per-direction LRU buffers
// sized to max entries each. It starts disabled (calls to Note are
// silently dropped until SetEnabled(true) is called).
//
// A max of 0 or negative disables recording entirely.
func NewAggregator(max int) *Aggregator {
return &Aggregator{
max: max,
outgoing: newDirBuf(),
incoming: newDirBuf(),
}
}
// SetEnabled sets the runtime enable flag. When false, Note is a silent
// no-op. SetEnabled returns the previous value.
func (a *Aggregator) SetEnabled(v bool) (prev bool) {
return a.enabled.Swap(v)
}
// Enabled reports whether the aggregator is currently enabled.
func (a *Aggregator) Enabled() bool { return a.enabled.Load() }
// Note records a rejection observation. It is a no-op if the aggregator
// is disabled, if max is non-positive, or if e.Direction is not a
// routable value (Outgoing or Incoming).
//
// If an existing entry matches the aggregation key
// (Direction, Proto, peer-address, Reason), its Last, Count, Src, Dst,
// Source, and MaybeBroken are updated with the new observation, and it
// is moved to the "most recent" end of its buffer. Otherwise a new entry
// is appended, evicting the oldest entry if the buffer is over capacity.
func (a *Aggregator) Note(e Event) {
if !a.enabled.Load() || a.max <= 0 {
return
}
buf := a.bufFor(e.Direction)
if buf == nil {
return
}
if e.Last.IsZero() {
e.Last = time.Now()
}
if e.First.IsZero() {
e.First = e.Last
}
if e.Count == 0 {
e.Count = 1
}
k := aggKey{
dir: e.Direction,
proto: e.Proto,
addr: e.peerAddr(),
reason: e.Reason,
}
a.mu.Lock()
defer a.mu.Unlock()
if el, ok := buf.m[k]; ok {
ent := el.Value.(*entry)
ent.e.Last = e.Last
ent.e.Count += e.Count
ent.e.Src = e.Src
ent.e.Dst = e.Dst
ent.e.Source = e.Source
ent.e.MaybeBroken = e.MaybeBroken
buf.order.MoveToBack(el)
return
}
buf.m[k] = buf.order.PushBack(&entry{k: k, e: e})
// Evict oldest if over capacity.
for buf.order.Len() > a.max {
old := buf.order.Front()
delete(buf.m, old.Value.(*entry).k)
buf.order.Remove(old)
}
}
// Outgoing returns a snapshot of the outgoing-rejection events in
// oldest-to-newest order.
func (a *Aggregator) Outgoing() []Event { return a.snapshot(&a.outgoing) }
// Incoming returns a snapshot of the incoming-rejection events in
// oldest-to-newest order.
func (a *Aggregator) Incoming() []Event { return a.snapshot(&a.incoming) }
func (a *Aggregator) snapshot(buf *dirBuf) []Event {
a.mu.Lock()
defer a.mu.Unlock()
out := make([]Event, 0, buf.order.Len())
for el := buf.order.Front(); el != nil; el = el.Next() {
out = append(out, el.Value.(*entry).e)
}
return out
}
// bufFor returns the internal buffer for a direction, or nil if the
// direction is not a recognized routable value.
//
// The returned pointer is stable for the lifetime of a; safe to use
// before acquiring a.mu.
func (a *Aggregator) bufFor(d Direction) *dirBuf {
switch d {
case Outgoing:
return &a.outgoing
case Incoming:
return &a.incoming
}
return nil
}
// DefaultMax returns the default maximum number of entries per
// direction. On mobile platforms the default is smaller to respect tight
// memory budgets.
func DefaultMax() int {
switch runtime.GOOS {
case "ios", "android", "tvos":
return 32
}
return 256
}
// DebugRejectsResponse is the JSON response body shared by the
// debug-rejects LocalAPI endpoint, the GET /debug/rejects c2n endpoint,
// and the [client/local.Client.DebugRejects] method.
type DebugRejectsResponse struct {
// Enabled is whether [nodecap.ConnReject] is currently set
// on the node. When false, Outgoing and Incoming will be empty
// regardless of prior activity.
Enabled bool
// Outgoing is the list of rejections observed for connections this
// node initiated.
Outgoing []Event
// Incoming is the list of rejections this node has emitted for
// connections initiated by peers.
Incoming []Event
}
+71 -8
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:generate go run tailscale.com/cmd/viewer --type=Config --clonefunc
@@ -21,10 +21,15 @@ import (
"tailscale.com/net/tsaddr"
"tailscale.com/types/dnstype"
"tailscale.com/util/dnsname"
"tailscale.com/util/set"
)
// Config is a DNS configuration.
type Config struct {
// AcceptDNS true if [Prefs.CorpDNS] is enabled (or --accept-dns=true).
// This should be used for error handling and health reporting
// purposes only.
AcceptDNS bool
// DefaultResolvers are the DNS resolvers to use for DNS names
// which aren't covered by more specific per-domain routes below.
// If empty, the OS's default resolvers (the ones that predate
@@ -48,9 +53,22 @@ type Config struct {
// it to resolve, you also need to add appropriate routes to
// Routes.
Hosts map[dnsname.FQDN][]netip.Addr
// SubdomainHosts is a set of FQDNs from Hosts that should also
// resolve subdomain queries to the same IPs. For example, if
// "node.tailnet.ts.net" is in SubdomainHosts, then queries for
// "anything.node.tailnet.ts.net" will resolve to node's IPs.
SubdomainHosts set.Set[dnsname.FQDN]
// OnlyIPv6, if true, uses the IPv6 service IP (for MagicDNS)
// instead of the IPv4 version (100.100.100.100).
OnlyIPv6 bool
// MagicDNSHostsUnrouted is whether MagicDNS host records are
// served on demand via [resolver.MagicDNSHosts] (so are not
// listed in Hosts) without being covered by any Routes entry;
// that is, MagicDNS domain routing is off. It preserves the
// effect the node records had when they were listed in Hosts:
// hasHostsWithoutSplitDNSRoutes reports true, keeping quad-100
// in the OS resolver path so the names still resolve.
MagicDNSHostsUnrouted bool
}
var magicDNSDualStack = envknob.RegisterBool("TS_DEBUG_MAGIC_DNS_DUAL_STACK")
@@ -63,11 +81,9 @@ func (c *Config) serviceIPs(knobs *controlknobs.Knobs) []netip.Addr {
return []netip.Addr{tsaddr.TailscaleServiceIPv6()}
}
// TODO(bradfitz,mikeodr,raggi): include IPv6 here too; tailscale/tailscale#15404
// And add a controlknobs knob to disable dual stack.
//
// For now, opt-in for testing.
if magicDNSDualStack() {
// See https://github.com/tailscale/tailscale/issues/15404 for the background
// on the opt-in debug knob and the controlknob opt-out.
if magicDNSDualStack() || !knobs.ShouldForceRegisterMagicDNSIPv4Only() {
return []netip.Addr{
tsaddr.TailscaleServiceIP(),
tsaddr.TailscaleServiceIPv6(),
@@ -119,8 +135,13 @@ func (c Config) hasDefaultIPResolversOnly() bool {
// hasHostsWithoutSplitDNSRoutes reports whether c contains any Host entries
// that aren't covered by a SplitDNS route suffix.
func (c Config) hasHostsWithoutSplitDNSRoutes() bool {
// TODO(bradfitz): this could be more efficient, but we imagine
// the number of SplitDNS routes and/or hosts will be small.
if c.MagicDNSHostsUnrouted {
return true
}
// Hosts is small here on most platforms: per-node records are
// served via [resolver.MagicDNSHosts] (accounted for above), so
// only control's DNS.ExtraRecords remain. On Windows it still
// carries every node's records for the hosts-file path.
for host := range c.Hosts {
if !c.hasSplitDNSRouteForHost(host) {
return true
@@ -129,6 +150,19 @@ func (c Config) hasHostsWithoutSplitDNSRoutes() bool {
return false
}
// requiresPrimaryResolver reports whether c can only be served correctly with
// quad-100 installed as the OS's primary (catch-all) resolver, rather than
// scoped to a set of match domains.
//
// That's the case when c has names quad-100 must answer that no route suffix
// covers, so there is no suffix to scope to. dnsConfigForNetmap pairs every
// ExtraRecord it emits with a route, so in practice this is the
// MagicDNS-names-present but MagicDNS-domain-routing-off case
// (MagicDNSHostsUnrouted).
func (c Config) requiresPrimaryResolver() bool {
return c.hasHostsWithoutSplitDNSRoutes()
}
// hasSplitDNSRouteForHost reports whether c contains a SplitDNS route
// that contains hosts.
func (c Config) hasSplitDNSRouteForHost(host dnsname.FQDN) bool {
@@ -140,6 +174,35 @@ func (c Config) hasSplitDNSRouteForHost(host dnsname.FQDN) bool {
return false
}
// hasHostsWithoutReverseRoutes reports whether any PTR record synthesized by
// quad-100 from Hosts is outside the configured routes. Apple scoping must
// preserve these answers as well as forward records.
func (c Config) hasHostsWithoutReverseRoutes() bool {
for _, ips := range c.Hosts {
for _, ip := range ips {
if !ip.IsValid() || !c.hasSplitDNSRouteForHost(reverseDNSName(ip)) {
return true
}
}
}
return false
}
// reverseDNSName returns the PTR query name for a valid IP address.
func reverseDNSName(ip netip.Addr) dnsname.FQDN {
if ip.Is4() {
b := ip.As4()
return dnsname.FQDN(fmt.Sprintf("%d.%d.%d.%d.in-addr.arpa.", b[3], b[2], b[1], b[0]))
}
b := ip.As16()
const hex = "0123456789abcdef"
name := make([]byte, 0, 64+len("ip6.arpa."))
for i := len(b) - 1; i >= 0; i-- {
name = append(name, hex[b[i]&0xf], '.', hex[b[i]>>4], '.')
}
return dnsname.FQDN(append(name, "ip6.arpa."...))
}
func (c Config) hasDefaultResolvers() bool {
return len(c.DefaultResolvers) > 0
}
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux && !android && !ts_omit_dbus
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build (linux && !android) || freebsd || openbsd
+70 -2
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !android && !ios
@@ -26,8 +26,10 @@ import (
"tailscale.com/feature"
"tailscale.com/health"
"tailscale.com/hostinfo"
"tailscale.com/net/dns/resolvconffile"
"tailscale.com/net/tsaddr"
"tailscale.com/types/lazy"
"tailscale.com/types/logger"
"tailscale.com/util/dnsname"
"tailscale.com/util/eventbus"
@@ -277,6 +279,9 @@ func (m *directManager) rename(old, new string) error {
if !m.renameBroken {
err := m.fs.Rename(old, new)
if err == nil {
if new == resolvConf {
m.relabelResolvConf()
}
return nil
}
if runtime.GOOS == "linux" && distro.Get() == distro.Synology {
@@ -309,9 +314,55 @@ func (m *directManager) rename(old, new string) error {
return fmt.Errorf("remove of %q failed (%w) and so did truncate: %v", old, err, err2)
}
}
if new == resolvConf {
m.relabelResolvConf()
}
return nil
}
var restoreconPath lazy.SyncValue[string] // path to restorecon, or "" if absent
// relabelResolvConf restores the policy-default SELinux context on
// /etc/resolv.conf. Best effort: only runs when SELinux is enforcing. See:
//
// https://github.com/tailscale/tailscale/issues/20149.
func (m *directManager) relabelResolvConf() {
if runtime.GOOS != "linux" {
return
}
restorecon := restoreconPath.Get(func() string {
p, _ := exec.LookPath("restorecon")
return p
})
if restorecon == "" {
return
}
if !hostinfo.IsSELinuxEnforcing() {
// Clear any prior warning, e.g. if SELinux was turned off at runtime
// with setenforce(8) since the last failure.
m.health.SetHealthy(selinuxRelabelWarnable)
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// m.fs may be rooted at a test prefix; restorecon needs the real path.
actualPath := m.fs.ActualPath(resolvConf)
if out, err := exec.CommandContext(ctx, restorecon, actualPath).CombinedOutput(); err != nil {
m.logf("dns: restorecon of %q failed: %v, output: %s", actualPath, err, bytes.TrimSpace(out))
m.health.SetUnhealthy(selinuxRelabelWarnable, nil)
} else {
m.health.SetHealthy(selinuxRelabelWarnable)
}
}
var selinuxRelabelWarnable = health.Register(&health.Warnable{
Code: "resolv-conf-relabel-failed",
Severity: health.SeverityMedium,
Title: "DNS configuration issue",
Text: health.StaticMessage("Failed to restore the SELinux label on /etc/resolv.conf; DNS may break when other services manage the file. See https://github.com/tailscale/tailscale/issues/20149"),
})
// setWant sets the expected contents of /etc/resolv.conf, if any.
//
// A value of nil means no particular value is expected.
@@ -442,7 +493,9 @@ func (m *directManager) runFileWatcher() {
if !ok {
return
}
if err := watchFile(m.ctx, "/etc/", resolvConf, m.checkForFileTrample); err != nil {
dir := m.fs.ActualPath(filepath.Dir(resolvConf))
file := m.fs.ActualPath(resolvConf)
if err := watchFile(m.ctx, dir, file, m.checkForFileTrample); err != nil {
// This is all best effort for now, so surface warnings to users.
m.logf("dns: inotify: %s", err)
}
@@ -597,6 +650,19 @@ type wholeFileFS interface {
ReadFile(name string) ([]byte, error)
Remove(name string) error
Rename(oldName, newName string) error
// ActualPath returns the real filesystem path for the given absolute
// logical path. All other methods in this interface accept logical
// paths (like "/etc/resolv.conf") and translate them internally;
// ActualPath exposes that same translation for callers that need
// the real path for use outside the interface (e.g. setting up an
// inotify watch on the correct directory).
//
// For directFS with an empty prefix (production), the input is
// returned unchanged ("/etc" → "/etc"). For directFS with a test
// prefix like "/tmp/test123", the prefix is joined
// ("/etc" → "/tmp/test123/etc"). For wslFS the input is returned
// unchanged, since paths are passed through to wsl.exe as-is.
ActualPath(name string) string
Stat(name string) (isRegular bool, err error)
Truncate(name string) error
WriteFile(name string, contents []byte, perm os.FileMode) error
@@ -613,6 +679,8 @@ type directFS struct {
func (fs directFS) path(name string) string { return filepath.Join(fs.prefix, name) }
func (fs directFS) ActualPath(name string) string { return fs.path(name) }
func (fs directFS) Stat(name string) (isRegular bool, err error) {
fi, err := os.Stat(fs.path(name))
if err != nil {
+24 -8
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by tailscale.com/cmd/cloner; DO NOT EDIT.
@@ -10,6 +10,7 @@ import (
"tailscale.com/types/dnstype"
"tailscale.com/util/dnsname"
"tailscale.com/util/set"
)
// Clone makes a deep copy of Config.
@@ -32,8 +33,19 @@ func (src *Config) Clone() *Config {
}
if dst.Routes != nil {
dst.Routes = map[dnsname.FQDN][]*dnstype.Resolver{}
for k := range src.Routes {
dst.Routes[k] = append([]*dnstype.Resolver{}, src.Routes[k]...)
for k, sv := range src.Routes {
if sv == nil {
dst.Routes[k] = nil
continue
}
dst.Routes[k] = make([]*dnstype.Resolver, len(sv))
for i := range sv {
if sv[i] == nil {
dst.Routes[k][i] = nil
} else {
dst.Routes[k][i] = sv[i].Clone()
}
}
}
}
dst.SearchDomains = append(src.SearchDomains[:0:0], src.SearchDomains...)
@@ -43,16 +55,20 @@ func (src *Config) Clone() *Config {
dst.Hosts[k] = append([]netip.Addr{}, src.Hosts[k]...)
}
}
dst.SubdomainHosts = src.SubdomainHosts.Clone()
return dst
}
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _ConfigCloneNeedsRegeneration = Config(struct {
DefaultResolvers []*dnstype.Resolver
Routes map[dnsname.FQDN][]*dnstype.Resolver
SearchDomains []dnsname.FQDN
Hosts map[dnsname.FQDN][]netip.Addr
OnlyIPv6 bool
AcceptDNS bool
DefaultResolvers []*dnstype.Resolver
Routes map[dnsname.FQDN][]*dnstype.Resolver
SearchDomains []dnsname.FQDN
Hosts map[dnsname.FQDN][]netip.Addr
SubdomainHosts set.Set[dnsname.FQDN]
OnlyIPv6 bool
MagicDNSHostsUnrouted bool
}{})
// Clone duplicates src into dst and reports whether it succeeded.
+34 -8
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by tailscale/cmd/viewer; DO NOT EDIT.
@@ -15,6 +15,7 @@ import (
"tailscale.com/types/dnstype"
"tailscale.com/types/views"
"tailscale.com/util/dnsname"
"tailscale.com/util/set"
)
//go:generate go run tailscale.com/cmd/cloner -clonefunc=true -type=Config
@@ -86,6 +87,11 @@ func (v *ConfigView) UnmarshalJSONFrom(dec *jsontext.Decoder) error {
return nil
}
// AcceptDNS true if [Prefs.CorpDNS] is enabled (or --accept-dns=true).
// This should be used for error handling and health reporting
// purposes only.
func (v ConfigView) AcceptDNS() bool { return v.ж.AcceptDNS }
// DefaultResolvers are the DNS resolvers to use for DNS names
// which aren't covered by more specific per-domain routes below.
// If empty, the OS's default resolvers (the ones that predate
@@ -123,16 +129,36 @@ func (v ConfigView) Hosts() views.MapSlice[dnsname.FQDN, netip.Addr] {
return views.MapSliceOf(v.ж.Hosts)
}
// SubdomainHosts is a set of FQDNs from Hosts that should also
// resolve subdomain queries to the same IPs. For example, if
// "node.tailnet.ts.net" is in SubdomainHosts, then queries for
// "anything.node.tailnet.ts.net" will resolve to node's IPs.
func (v ConfigView) SubdomainHosts() views.Map[dnsname.FQDN, struct{}] {
return views.MapOf(v.ж.SubdomainHosts)
}
// OnlyIPv6, if true, uses the IPv6 service IP (for MagicDNS)
// instead of the IPv4 version (100.100.100.100).
func (v ConfigView) OnlyIPv6() bool { return v.ж.OnlyIPv6 }
func (v ConfigView) Equal(v2 ConfigView) bool { return v.ж.Equal(v2.ж) }
func (v ConfigView) OnlyIPv6() bool { return v.ж.OnlyIPv6 }
// MagicDNSHostsUnrouted is whether MagicDNS host records are
// served on demand via [resolver.MagicDNSHosts] (so are not
// listed in Hosts) without being covered by any Routes entry;
// that is, MagicDNS domain routing is off. It preserves the
// effect the node records had when they were listed in Hosts:
// hasHostsWithoutSplitDNSRoutes reports true, keeping quad-100
// in the OS resolver path so the names still resolve.
func (v ConfigView) MagicDNSHostsUnrouted() bool { return v.ж.MagicDNSHostsUnrouted }
func (v ConfigView) Equal(v2 ConfigView) bool { return v.ж.Equal(v2.ж) }
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _ConfigViewNeedsRegeneration = Config(struct {
DefaultResolvers []*dnstype.Resolver
Routes map[dnsname.FQDN][]*dnstype.Resolver
SearchDomains []dnsname.FQDN
Hosts map[dnsname.FQDN][]netip.Addr
OnlyIPv6 bool
AcceptDNS bool
DefaultResolvers []*dnstype.Resolver
Routes map[dnsname.FQDN][]*dnstype.Resolver
SearchDomains []dnsname.FQDN
Hosts map[dnsname.FQDN][]netip.Addr
SubdomainHosts set.Set[dnsname.FQDN]
OnlyIPv6 bool
MagicDNSHostsUnrouted bool
}{})
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !windows
+57 -11
View File
@@ -1,34 +1,80 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package dns
import (
"fmt"
"log"
"os/exec"
"syscall"
"time"
"golang.org/x/sys/windows"
"tailscale.com/util/coalescedop"
)
func flushCaches() error {
var (
flushDNSOp = coalescedop.New(doFlushDNS)
registerDNSOp = coalescedop.New(doRegisterDNS)
)
func doFlushDNS() {
t0 := time.Now()
cmd := exec.Command("ipconfig", "/flushdns")
cmd.SysProcAttr = &syscall.SysProcAttr{
CreationFlags: windows.DETACHED_PROCESS,
}
out, err := cmd.CombinedOutput()
err := cmd.Run()
d := time.Since(t0).Round(time.Millisecond)
if err != nil {
return fmt.Errorf("%v (output: %s)", err, out)
log.Printf("error running ipconfig /flushdns after %v: %v", d, err)
} else {
log.Printf("ran ipconfig /flushdns in %v", d)
}
}
func doRegisterDNS() {
t0 := time.Now()
cmd := exec.Command("ipconfig", "/registerdns")
cmd.SysProcAttr = &syscall.SysProcAttr{
CreationFlags: windows.DETACHED_PROCESS,
}
err := cmd.Run()
d := time.Since(t0).Round(time.Millisecond)
if err != nil {
log.Printf("error running ipconfig /registerdns after %v: %v", d, err)
} else {
log.Printf("ran ipconfig /registerdns in %v", d)
}
}
func flushCaches() error {
flushDNSOp.Do()
return nil
}
// Flush clears the local resolver cache.
//
// Only Windows has a public dns.Flush, needed in router_windows.go. Other
// platforms like Linux need a different flush implementation depending on
// the DNS manager. There is a FlushCaches method on the manager which
// can be used on all platforms.
func Flush() error {
return flushCaches()
// Only Windows has a public dns.Flush, needed in tailscaled_windows.go.
// Other platforms like Linux need a different flush implementation
// depending on the DNS manager. There is a FlushCaches method on the manager
// which can be used on all platforms.
//
// Flush is non-blocking; it coalesces concurrent and rapid successive
// calls so that at most one ipconfig /flushdns is in flight at a time,
// with at most one additional execution queued.
func Flush() {
flushDNSOp.Do()
}
// registerDNS forces DNS re-registration in Active Directory. This
// invokes an undocumented hidden function that forces Windows to
// notice that adapter settings have changed, which makes the DNS
// settings actually take effect.
//
// registerDNS is non-blocking; it coalesces concurrent and rapid
// successive calls so that at most one ipconfig /registerdns is in
// flight at a time, with at most one additional execution queued.
func registerDNS() {
registerDNSOp.Do()
}
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build windows
+275 -62
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package dns
@@ -20,6 +20,7 @@ import (
"time"
"tailscale.com/control/controlknobs"
"tailscale.com/envknob"
"tailscale.com/feature/buildfeatures"
"tailscale.com/health"
"tailscale.com/net/dns/resolver"
@@ -33,6 +34,7 @@ import (
"tailscale.com/util/eventbus"
"tailscale.com/util/slicesx"
"tailscale.com/util/syspolicy/policyclient"
"tailscale.com/version"
)
var (
@@ -40,12 +42,28 @@ var (
// ErrNoDNSConfig is returned by RecompileDNSConfig when the Manager
// has no existing DNS configuration.
ErrNoDNSConfig = errors.New("no DNS configuration")
// errEmptyBaseConfig is returned by compileConfig when the OS has no
// upstream resolvers to forward the default route to. See
// https://github.com/tailscale/tailscale/issues/20341
errEmptyBaseConfig = errors.New("no upstream resolvers in OS base config")
)
// Bounds for [Manager.retryEmptyBaseConfig]. The delay doubles each attempt,
// so these give up after roughly a minute.
const (
baseConfigRetryInterval = 1 * time.Second
baseConfigRetryAttempts = 6
)
// maxActiveQueries returns the maximal number of DNS requests that can
// be running.
const maxActiveQueries = 256
// ResponseMapper is a function that accepts the bytes representing
// a DNS response and returns bytes representing a DNS response.
// Used to observe and/or mutate DNS responses managed by this manager.
type ResponseMapper func([]byte) []byte
// We use file-ignore below instead of ignore because on some platforms,
// the lint exception is necessary and on others it is not,
// and plain ignore complains if the exception is unnecessary.
@@ -67,8 +85,10 @@ type Manager struct {
knobs *controlknobs.Knobs // or nil
goos string // if empty, gets set to runtime.GOOS
mu sync.Mutex // guards following
config *Config // Tracks the last viable DNS configuration set by Set. nil on failures other than compilation failures or if set has never been called.
mu sync.Mutex // guards following
config *Config // Tracks the last viable DNS configuration set by Set. nil on failures other than compilation failures or if set has never been called.
queryResponseMapper ResponseMapper
waitingForBaseCfg bool // a retry goroutine is waiting for OS upstream resolvers
}
// NewManager created a new manager from the given config.
@@ -125,6 +145,16 @@ func (m *Manager) Resolver() *resolver.Resolver {
return m.resolver
}
// ProbeLocks acquires and releases the manager's internal mutexes.
func (m *Manager) ProbeLocks() {
m.mu.Lock()
m.mu.Unlock()
if r := m.Resolver(); r != nil {
r.ProbeLocks()
}
}
// RecompileDNSConfig recompiles the last attempted DNS configuration, which has
// the side effect of re-querying the OS's interface nameservers. This should be used
// on platforms where the interface nameservers can change. Darwin, for example,
@@ -199,6 +229,7 @@ func (m *Manager) setLocked(cfg Config) error {
}
m.health.SetHealthy(osConfigurationSetWarnable)
m.health.SetHealthy(EmptyBaseConfigWarnable)
m.config = &cfg
return nil
@@ -264,7 +295,10 @@ func compileHostEntries(cfg Config) (hosts []*HostEntry) {
return hosts
}
var osConfigurationReadWarnable = health.Register(&health.Warnable{
// OSConfigurationReadWarnable is a Warnable set when Tailscale cannot read the
// DNS configuration the OS was using before Tailscale took over. It is
// exported so that a test can name it rather than repeat its wording.
var OSConfigurationReadWarnable = health.Register(&health.Warnable{
Code: "dns-read-os-config-failed",
Title: "Failed to read system DNS configuration",
Text: func(args health.Args) string {
@@ -274,6 +308,17 @@ var osConfigurationReadWarnable = health.Register(&health.Warnable{
DependsOn: []*health.Warnable{health.NetworkStatusWarnable},
})
// EmptyBaseConfigWarnable warns that MagicDNS is inactive because the OS has no
// upstream resolvers to forward the default route to. It is exported so that a
// test can name it rather than repeat its wording.
var EmptyBaseConfigWarnable = health.Register(&health.Warnable{
Code: "dns-empty-base-config",
Title: "Waiting for system DNS configuration",
Text: health.StaticMessage("Your device has no system DNS servers for Tailscale to forward queries to, so Tailscale has left DNS alone. MagicDNS stays inactive until the system has DNS servers of its own."),
Severity: health.SeverityMedium,
DependsOn: []*health.Warnable{health.NetworkStatusWarnable},
})
var osConfigurationSetWarnable = health.Register(&health.Warnable{
Code: "dns-set-os-config-failed",
Title: "Failed to set system DNS configuration",
@@ -291,6 +336,8 @@ func (m *Manager) compileConfig(cfg Config) (rcfg resolver.Config, ocfg OSConfig
// authoritative suffixes, even if we don't propagate MagicDNS to
// the OS.
rcfg.Hosts = cfg.Hosts
rcfg.SubdomainHosts = cfg.SubdomainHosts
rcfg.AcceptDNS = cfg.AcceptDNS
routes := map[dnsname.FQDN][]*dnstype.Resolver{} // assigned conditionally to rcfg.Routes below.
var propagateHostsToOS bool
for suffix, resolvers := range cfg.Routes {
@@ -301,8 +348,21 @@ func (m *Manager) compileConfig(cfg Config) (rcfg resolver.Config, ocfg OSConfig
routes[suffix] = resolvers
}
}
// LocalDomains is an unordered suffix set, but it comes out of map
// iteration; sort it so equal configs compare and log equal.
slices.Sort(rcfg.LocalDomains)
// Similarly, the OS always gets search paths.
isWindows := m.goos == "windows"
isIOS := m.goos == "ios"
isSandboxedMac := m.goos == "darwin" && isSandboxedMacOS()
supportsSplitDNS := m.os.SupportsSplitDNS()
isSandboxedApple := isIOS || isSandboxedMac
// Preserve configured search domains in control's order (tailnet first).
// Do not add split-DNS suffixes: restricted resolvers are match-only.
// LAN-provided search domains are appended below, at lowest priority.
// The Apple extension only installs this list in primary resolver mode;
// scoped mode uses the match domains as its global search list.
ocfg.SearchDomains = cfg.SearchDomains
if propagateHostsToOS && m.goos == "windows" {
ocfg.Hosts = compileHostEntries(cfg)
@@ -355,16 +415,17 @@ func (m *Manager) compileConfig(cfg Config) (rcfg resolver.Config, ocfg OSConfig
// quad-100 will still have the full split configuration as well,
// and so can service WSL requests correctly.
//
// This bool is used in a couple of places below to implement this
// workaround.
isWindows := m.goos == "windows"
isApple := (m.goos == "darwin" || m.goos == "ios")
if len(cfg.singleResolverSet()) > 0 && m.os.SupportsSplitDNS() && !isWindows && !isApple {
// Split DNS configuration requested, where all split domains
// go to the same resolvers. We can let the OS do it.
ocfg.Nameservers = toIPsOnly(cfg.singleResolverSet())
ocfg.MatchDomains = cfg.matchDomains()
return rcfg, ocfg, nil
// Apple platforms keep split-domain traffic pointed at quad-100 rather than
// handing the upstream resolvers to the OS directly, because those resolvers
// may only be reachable through the tunnel.
if supportsSplitDNS && !isWindows && !isSandboxedApple {
if srs := toIPsOnly(cfg.singleResolverSet()); len(srs) > 0 {
// Split DNS configuration requested, where all split domains
// go to the same resolvers. We can let the OS do it.
ocfg.Nameservers = srs
ocfg.MatchDomains = cfg.matchDomains()
return rcfg, ocfg, nil
}
}
// Split DNS configuration with either multiple upstream routes,
@@ -373,66 +434,203 @@ func (m *Manager) compileConfig(cfg Config) (rcfg resolver.Config, ocfg OSConfig
rcfg.Routes = routes
ocfg.Nameservers = cfg.serviceIPs(m.knobs)
var baseCfg *OSConfig // base config; non-nil if/when known
// Apple tunnels can contribute global search domains only through
// matchDomains with matchDomainsNoSearch=false; NEDNSSettings.searchDomains
// is stripped by configd. Scope only simple configs (Mode A), otherwise
// install quad-100 as primary (Mode B) and keep split suffixes internal.
// This prevents custom split suffixes from becoming search domains while
// keeping bare tailnet names reachable. See tailscale/corp#48693.
//
// Both forward records and the PTR records synthesized from Hosts must
// be covered. Scoping is opt-out on iOS and opt-in on sandboxed macOS.
appleScopeEnabled := (isIOS && !m.disableSplitDNSOptimization()) ||
(isSandboxedMac && m.scopeQuad100OnMacOS())
scopeApple := appleScopeEnabled && rcfg.RoutesRequireNoCustomResolvers() &&
!cfg.requiresPrimaryResolver() && !cfg.hasHostsWithoutReverseRoutes()
// Even though Apple devices can do split DNS, they don't provide a way to
// selectively answer ExtraRecords, and ignore other DNS traffic. As a
// workaround, we read the existing default resolver configuration and use
// that as the forwarder for all DNS traffic that quad-100 doesn't handle.
if isApple || !m.os.SupportsSplitDNS() {
// If the OS can't do native split-dns, read out the underlying
// resolver config and blend it into our config. On apple platforms, [OSConfigurator.GetBaseConfig]
// has a tendency to temporarily fail if called immediately following
// an interface change. These failures should be retried if/when the OS
// indicates that the DNS configuration has changed via [RecompileDNSConfig].
cfg, err := m.os.GetBaseConfig()
if err == nil {
baseCfg = &cfg
} else if isApple && err == ErrGetBaseConfigNotSupported {
// This is currently (2022-10-13) expected on certain iOS and macOS
// builds.
} else {
m.health.SetUnhealthy(osConfigurationReadWarnable, health.Args{health.ArgError: err.Error()})
return resolver.Config{}, OSConfig{}, err
}
m.health.SetHealthy(osConfigurationReadWarnable)
// iOS still reads the base config below, even when scoped, so direct
// queries to quad-100 can be forwarded to the underlying resolver.
if supportsSplitDNS && !isIOS && (!isSandboxedApple || scopeApple) && !cfg.requiresPrimaryResolver() {
ocfg.MatchDomains = cfg.matchDomains()
return rcfg, ocfg, nil
}
if baseCfg == nil {
// If there was no base config, then we need to fallback to SplitDNS mode.
ocfg.MatchDomains = cfg.matchDomains()
} else {
// On iOS only (for now), check if all route names point to resources inside the tailnet.
// If so, we can set those names as MatchDomains to enable a split DNS configuration
// which will help preserve battery life.
// Because on iOS MatchDomains must equal SearchDomains, we cannot do this when
// we have any Routes outside the tailnet. Otherwise when app connectors are enabled,
// a query for 'work-laptop' might lead to search domain expansion, resolving
// as 'work-laptop.aws.com' for example.
if m.goos == "ios" && rcfg.RoutesRequireNoCustomResolvers() {
if !m.disableSplitDNSOptimization() {
for r := range rcfg.Routes {
ocfg.MatchDomains = append(ocfg.MatchDomains, r)
}
} else {
m.logf("iOS split DNS is disabled by nodeattr")
// When quad-100 is primary (or the OS cannot do split DNS), use the
// underlying resolver for queries quad-100 cannot answer locally. On iOS,
// [OSConfigurator.GetBaseConfig] can temporarily fail immediately after an
// interface change. These failures should be retried if/when the OS indicates
// that the DNS configuration has changed via [RecompileDNSConfig].
base, err := m.os.GetBaseConfig()
if err != nil {
if errors.Is(err, ErrGetBaseConfigNoResolvers) {
m.health.SetHealthy(OSConfigurationReadWarnable)
m.health.SetUnhealthy(EmptyBaseConfigWarnable, nil)
if !isSandboxedApple {
m.retryEmptyBaseConfig()
}
return resolver.Config{}, OSConfig{}, err
}
var defaultRoutes []*dnstype.Resolver
for _, ip := range baseCfg.Nameservers {
defaultRoutes = append(defaultRoutes, &dnstype.Resolver{Addr: ip.String()})
canScopeWithoutBase := !isSandboxedApple && (isNoopManager(m.os) || supportsSplitDNS) ||
isIOS && supportsSplitDNS && scopeApple
if canScopeWithoutBase && err == ErrGetBaseConfigNotSupported {
// Some managers have no base config by construction. Apple Mode B
// cannot fall back to scoping: it would lose unrouted records or
// expose split suffixes as search domains. Nor can it install a
// catch-all without forwarding upstreams; report the read error.
// Sandboxed macOS has /etc/resolv.conf, so an unsupported read
// remains an error there, even for an otherwise simple config.
m.health.SetHealthy(OSConfigurationReadWarnable)
ocfg.MatchDomains = cfg.matchDomains()
return rcfg, ocfg, nil
}
m.health.SetUnhealthy(OSConfigurationReadWarnable, health.Args{health.ArgError: err.Error()})
return resolver.Config{}, OSConfig{}, err
}
m.health.SetHealthy(OSConfigurationReadWarnable)
defaultRoutes := underlyingResolvers(base)
if len(defaultRoutes) == 0 && (!isSandboxedApple || !scopeApple) {
// Taking over here would point the OS at quad-100 with an empty "."
// route, failing every non-Tailscale name. Leave the OS config alone
// and retry until resolvers appear. Check the recovered resolvers,
// which may carry endpoints not representable in base.Nameservers.
// Apple extensions trigger recompilation on DNS changes instead of
// using the retry goroutine. Apple scoped mode does not need upstreams
// for queries left to the OS, so it tolerates a successful empty read.
m.logf("no upstream resolvers in OS base config; not taking over DNS")
m.health.SetUnhealthy(EmptyBaseConfigWarnable, nil)
if !isSandboxedApple {
m.retryEmptyBaseConfig()
}
return resolver.Config{}, OSConfig{}, errEmptyBaseConfig
} else if len(defaultRoutes) == 0 {
m.logf("dns: base config has no resolvers; quad-100 has no upstream for non-tailnet queries")
}
if isIOS && supportsSplitDNS && scopeApple {
// Include the authoritative MagicDNS roots, not just upstream routes.
// Do not union search-only domains: that would capture their queries.
ocfg.MatchDomains = cfg.matchDomains()
}
rcfg.Routes["."] = defaultRoutes
// Append base config search domains, but only if not already present.
// This prevents duplicates when GetBaseConfig() reads back domains that
// Tailscale itself previously wrote to resolv.conf.
for _, domain := range base.SearchDomains {
if !slices.Contains(ocfg.SearchDomains, domain) {
ocfg.SearchDomains = append(ocfg.SearchDomains, domain)
}
rcfg.Routes["."] = defaultRoutes
ocfg.SearchDomains = append(ocfg.SearchDomains, baseCfg.SearchDomains...)
}
return rcfg, ocfg, nil
}
// underlyingResolvers returns the resolvers that quad-100 should forward
// non-tailnet queries to, derived from the OS's base configuration.
//
// Platforms that can recover the underlying configuration with more detail
// than IP addresses (non-standard ports, DoH/DoT endpoints) populate
// [OSConfig.Resolvers], which preserves that detail end to end. Otherwise we
// fall back to plain IP:53 resolvers built from Nameservers. Note that the
// result can be empty: callers that cannot serve queries without a catch-all
// forwarder must check and handle that case.
func underlyingResolvers(base OSConfig) []*dnstype.Resolver {
if len(base.Resolvers) > 0 {
out := make([]*dnstype.Resolver, 0, len(base.Resolvers))
for _, r := range base.Resolvers {
if r == nil || r.Addr == "" {
continue
}
out = append(out, r.Clone())
}
if len(out) > 0 {
return out
}
}
var out []*dnstype.Resolver
for _, ip := range base.Nameservers {
out = append(out, &dnstype.Resolver{Addr: ip.String()})
}
return out
}
func (m *Manager) disableSplitDNSOptimization() bool {
return m.knobs != nil && m.knobs.DisableSplitDNSWhenNoCustomResolvers.Load()
}
var scopeQuad100OnMacOSEnv = envknob.RegisterOptBool("TS_DEBUG_SCOPE_QUAD100_MACOS")
// scopeQuad100OnMacOS reports whether sandboxed macOS should scope quad-100 to
// its match domains for eligible simple configs, rather than installing it
// as the OS's primary resolver. Off (false) unless control sets
// NodeAttrScopeQuad100OnMacOS, or TS_DEBUG_SCOPE_QUAD100_MACOS is set.
// See tailscale/corp#45534.
func (m *Manager) scopeQuad100OnMacOS() bool {
if v, ok := scopeQuad100OnMacOSEnv().Get(); ok {
return v
}
return m.knobs != nil && m.knobs.ScopeQuad100OnMacOS.Load()
}
// retryEmptyBaseConfig starts a goroutine that reapplies the last config until
// the OS has upstream resolvers, giving up after baseConfigRetryAttempts. A
// later [Manager.Set] or link change starts it again.
//
// Only one runs at a time: each attempt re-enters compileConfig, which calls
// back here while the base config is still empty.
//
// m.mu must be held.
func (m *Manager) retryEmptyBaseConfig() {
syncs.AssertLocked(&m.mu)
if m.waitingForBaseCfg {
return
}
m.waitingForBaseCfg = true
go func() {
defer func() {
m.mu.Lock()
defer m.mu.Unlock()
m.waitingForBaseCfg = false
}()
d := baseConfigRetryInterval
for range baseConfigRetryAttempts {
select {
case <-m.ctx.Done():
return
case <-time.After(d):
}
d *= 2
switch err := m.reapplyConfig(); {
case err == nil:
m.logf("OS upstream resolvers appeared; DNS configured")
return
case errors.Is(err, errEmptyBaseConfig):
// Keep waiting.
case errors.Is(err, net.ErrClosed):
return
default:
// Could be transient, e.g. a failed OS config read, so keep
// waiting rather than giving up early.
m.logf("error reapplying DNS config: %v", err)
}
}
m.logf("gave up waiting for OS upstream resolvers")
}()
}
// reapplyConfig reapplies the last config, returning [net.ErrClosed] if
// [Manager.Down] has run or there is no config to apply.
func (m *Manager) reapplyConfig() error {
m.mu.Lock()
defer m.mu.Unlock()
if m.ctx.Err() != nil || m.config == nil {
return net.ErrClosed
}
return m.setLocked(*m.config)
}
var isSandboxedMacOS = version.IsSandboxedMacOS
// toIPsOnly returns only the IP portion of dnstype.Resolver.
// Only safe to use if the resolvers slice has been cleared of
// DoH or custom-port entries with something like hasDefaultIPResolversOnly.
@@ -465,7 +663,16 @@ func (m *Manager) Query(ctx context.Context, bs []byte, family string, from neti
return nil, errFullQueue
}
defer atomic.AddInt32(&m.activeQueriesAtomic, -1)
return m.resolver.Query(ctx, bs, family, from)
outbs, err := m.resolver.Query(ctx, bs, family, from)
if err != nil {
return outbs, err
}
m.mu.Lock()
defer m.mu.Unlock()
if m.queryResponseMapper != nil {
outbs = m.queryResponseMapper(outbs)
}
return outbs, err
}
const (
@@ -480,7 +687,7 @@ const (
maxReqSizeTCP = 4096
)
// TrampleDNS is an an event indicating we detected that DNS config was
// TrampleDNS is an event indicating we detected that DNS config was
// overwritten by another process.
type TrampleDNS struct {
LastTrample time.Time
@@ -651,3 +858,9 @@ func CleanUp(logf logger.Logf, netMon *netmon.Monitor, bus *eventbus.Bus, health
}
var metricDNSQueryErrorQueue = clientmetric.NewCounter("dns_query_local_error_queue")
func (m *Manager) SetQueryResponseMapper(fx ResponseMapper) {
m.mu.Lock()
defer m.mu.Unlock()
m.queryResponseMapper = fx
}
+156 -17
View File
@@ -1,11 +1,16 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package dns
import (
"bytes"
"fmt"
"io/fs"
"os"
"os/exec"
"strconv"
"strings"
"go4.org/mem"
"tailscale.com/control/controlknobs"
@@ -22,20 +27,31 @@ import (
//
// The health tracker, bus and the knobs may be nil and are ignored on this platform.
func NewOSConfigurator(logf logger.Logf, _ *health.Tracker, _ *eventbus.Bus, _ policyclient.Client, _ *controlknobs.Knobs, ifName string) (OSConfigurator, error) {
return &darwinConfigurator{logf: logf, ifName: ifName}, nil
return &darwinConfigurator{
logf: logf,
ifName: ifName,
resolverDir: "/etc/resolver",
resolvConfPath: "/etc/resolv.conf",
runScutil: runScutil,
}, nil
}
// darwinConfigurator is the tailscaled-on-macOS DNS OS configurator that
// maintains the Split DNS nameserver entries pointing MagicDNS DNS suffixes
// to 100.100.100.100 using the macOS /etc/resolver/$SUFFIX files.
type darwinConfigurator struct {
logf logger.Logf
ifName string
logf logger.Logf
ifName string
resolverDir string // default "/etc/resolver"
resolvConfPath string // default "/etc/resolv.conf"
runScutil func(script string) (output string, err error)
}
func (c *darwinConfigurator) Close() error {
c.removeResolverFiles(func(domain string) bool { return true })
return nil
if err := c.removeGlobalDNS(); err != nil {
return err
}
return c.removeResolverFiles(func(domain string) bool { return true })
}
func (c *darwinConfigurator) SupportsSplitDNS() bool {
@@ -43,6 +59,16 @@ func (c *darwinConfigurator) SupportsSplitDNS() bool {
}
func (c *darwinConfigurator) SetDNS(cfg OSConfig) error {
if len(cfg.Nameservers) > 0 && len(cfg.MatchDomains) == 0 {
if err := c.setGlobalDNS(cfg); err != nil {
return err
}
return c.removeResolverFiles(func(domain string) bool { return true })
}
if err := c.removeGlobalDNS(); err != nil {
return err
}
var buf bytes.Buffer
buf.WriteString(macResolverFileHeader)
for _, ip := range cfg.Nameservers {
@@ -51,10 +77,16 @@ func (c *darwinConfigurator) SetDNS(cfg OSConfig) error {
buf.WriteString("\n")
}
if err := os.MkdirAll("/etc/resolver", 0755); err != nil {
if err := os.MkdirAll(c.resolverDir, 0755); err != nil {
return err
}
root, err := os.OpenRoot(c.resolverDir)
if err != nil {
return err
}
defer root.Close()
var keep map[string]bool
// Add a dummy file to /etc/resolver with a "search ..." directive if we have
@@ -70,7 +102,7 @@ func (c *darwinConfigurator) SetDNS(cfg OSConfig) error {
sbuf.WriteString(string(d.WithoutTrailingDot()))
}
sbuf.WriteString("\n")
if err := os.WriteFile("/etc/resolver/"+searchFile, sbuf.Bytes(), 0644); err != nil {
if err := root.WriteFile(searchFile, sbuf.Bytes(), 0644); err != nil {
return err
}
}
@@ -78,15 +110,117 @@ func (c *darwinConfigurator) SetDNS(cfg OSConfig) error {
for _, d := range cfg.MatchDomains {
fileBase := string(d.WithoutTrailingDot())
mak.Set(&keep, fileBase, true)
fullPath := "/etc/resolver/" + fileBase
if err := os.WriteFile(fullPath, buf.Bytes(), 0644); err != nil {
if !isValidResolverFileName(fileBase) {
c.logf("[unexpected] invalid resolver domain %q with slashes or colons", fileBase)
return fmt.Errorf("invalid resolver domain %q: must not contain slashes or colons", fileBase)
}
if err := root.WriteFile(fileBase, buf.Bytes(), 0644); err != nil {
return err
}
}
return c.removeResolverFiles(func(domain string) bool { return !keep[domain] })
}
// macOSGlobalDNSKey is a stable synthetic SystemConfiguration service key for
// tailscaled's global DNS resolver. The UUID does not identify a real network
// service; it just gives tailscaled one well-known dynamic-store location to
// set and later remove.
const macOSGlobalDNSKey = "State:/Network/Service/FF457792-79C0-4A25-8392-D875BBEACCA6/DNS"
// setGlobalDNS installs cfg.Nameservers as a default resolver using
// SystemConfiguration's dynamic store. /etc/resolver is only a split-DNS
// mechanism; it cannot express a primary resolver.
func (c *darwinConfigurator) setGlobalDNS(cfg OSConfig) error {
var script strings.Builder
script.WriteString("d.init\n")
script.WriteString("d.add SearchOrder # 100000\n")
script.WriteString("d.add ServerAddresses *")
for _, ip := range cfg.Nameservers {
script.WriteByte(' ')
script.WriteString(ip.String())
}
script.WriteByte('\n')
// An empty SupplementalMatchDomains entry makes this a default resolver.
script.WriteString("d.add SupplementalMatchDomains * \"\"\n")
if len(cfg.SearchDomains) > 0 {
script.WriteString("d.add SearchDomains *")
for _, fqdn := range cfg.SearchDomains {
script.WriteByte(' ')
writeScutilString(&script, fqdn.WithoutTrailingDot())
}
script.WriteByte('\n')
}
script.WriteString("set ")
script.WriteString(macOSGlobalDNSKey)
script.WriteString("\nquit\n")
out, err := c.runScutilScript(script.String())
if err != nil {
return err
}
out = strings.TrimSpace(out)
if out != "" {
return fmt.Errorf("scutil set %s: %s", macOSGlobalDNSKey, out)
}
return nil
}
func (c *darwinConfigurator) removeGlobalDNS() error {
script := "remove " + macOSGlobalDNSKey + "\nquit\n"
out, err := c.runScutilScript(script)
if err != nil {
return err
}
out = strings.TrimSpace(out)
if out == "" || out == "No such key" {
return nil
}
return fmt.Errorf("scutil remove %s: %s", macOSGlobalDNSKey, out)
}
func (c *darwinConfigurator) runScutilScript(script string) (string, error) {
run := c.runScutil
if run == nil {
run = runScutil
}
return run(script)
}
func runScutil(script string) (string, error) {
cmd := exec.Command("/usr/sbin/scutil")
cmd.Stdin = strings.NewReader(script)
out, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("scutil: %w: %s", err, strings.TrimSpace(string(out)))
}
return string(out), nil
}
func writeScutilString(b *strings.Builder, v string) {
if v == "" || strings.ContainsAny(v, " \t\n\"") {
b.WriteString(strconv.Quote(v))
return
}
b.WriteString(v)
}
func isValidResolverFileName(name string) bool {
// Verify that the filename doesn't contain any characters that
// might cause issues when used as a filename; os.Root is a
// defense against path traversal, but prefer a nice error here
// if we can. These aren't valid for domain names anyway.
if strings.Contains(name, "/") || strings.Contains(name, "\\") {
return false
}
if strings.Contains(name, ":") {
return false
}
return true
}
// GetBaseConfig returns the current OS DNS configuration, extracting it from /etc/resolv.conf.
// We should really be using the SystemConfiguration framework to get this information, as this
// is not a stable public API, and is provided mostly as a compatibility effort with Unix
@@ -95,9 +229,9 @@ func (c *darwinConfigurator) SetDNS(cfg OSConfig) error {
func (c *darwinConfigurator) GetBaseConfig() (OSConfig, error) {
cfg := OSConfig{}
resolvConf, err := resolvconffile.ParseFile("/etc/resolv.conf")
resolvConf, err := resolvconffile.ParseFile(c.resolvConfPath)
if err != nil {
c.logf("failed to parse /etc/resolv.conf: %v", err)
c.logf("failed to parse %s: %v", c.resolvConfPath, err)
return cfg, ErrGetBaseConfigNotSupported
}
@@ -113,7 +247,7 @@ func (c *darwinConfigurator) GetBaseConfig() (OSConfig, error) {
if len(cfg.Nameservers) == 0 {
// Log a warning in case we couldn't find any nameservers in /etc/resolv.conf.
c.logf("no nameservers found in /etc/resolv.conf, DNS resolution might fail")
c.logf("no nameservers found in %s, DNS resolution might fail", c.resolvConfPath)
}
return cfg, nil
@@ -124,13 +258,19 @@ const macResolverFileHeader = "# Added by tailscaled\n"
// removeResolverFiles deletes all files in /etc/resolver for which the shouldDelete
// func returns true.
func (c *darwinConfigurator) removeResolverFiles(shouldDelete func(domain string) bool) error {
dents, err := os.ReadDir("/etc/resolver")
root, err := os.OpenRoot(c.resolverDir)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
defer root.Close()
dents, err := fs.ReadDir(root.FS(), ".")
if err != nil {
return err
}
for _, de := range dents {
if !de.Type().IsRegular() {
continue
@@ -139,8 +279,7 @@ func (c *darwinConfigurator) removeResolverFiles(shouldDelete func(domain string
if !shouldDelete(name) {
continue
}
fullPath := "/etc/resolver/" + name
contents, err := os.ReadFile(fullPath)
contents, err := root.ReadFile(name)
if err != nil {
if os.IsNotExist(err) { // race?
continue
@@ -150,7 +289,7 @@ func (c *darwinConfigurator) removeResolverFiles(shouldDelete func(domain string
if !mem.HasPrefix(mem.B(contents), mem.S(macResolverFileHeader)) {
continue
}
if err := os.Remove(fullPath); err != nil {
if err := root.Remove(name); err != nil {
return err
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build (!linux || android) && !freebsd && !openbsd && !windows && !darwin && !illumos && !solaris && !plan9
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package dns
+2 -2
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux && !android
@@ -380,7 +380,7 @@ func isLibnssResolveUsed(env newOSConfigEnv) error {
if err != nil {
return fmt.Errorf("reading /etc/resolv.conf: %w", err)
}
for _, line := range strings.Split(string(bs), "\n") {
for line := range strings.SplitSeq(string(bs), "\n") {
fields := strings.Fields(line)
if len(fields) < 2 || fields[0] != "hosts:" {
continue
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package dns
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// TODO: man 6 ndb | grep -e 'suffix.*same line'
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package dns
+137 -100
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package dns
@@ -8,15 +8,14 @@ import (
"bytes"
"errors"
"fmt"
"io/fs"
"maps"
"net/netip"
"os"
"os/exec"
"path/filepath"
"slices"
"sort"
"strings"
"syscall"
"time"
"golang.org/x/sys/windows"
@@ -34,6 +33,7 @@ import (
"tailscale.com/util/syspolicy/policyclient"
"tailscale.com/util/syspolicy/ptype"
"tailscale.com/util/winutil"
"tailscale.com/util/winutil/winenv"
)
const (
@@ -49,6 +49,9 @@ type windowsManager struct {
nrptDB *nrptRuleDatabase
wslManager *wslManager
polc policyclient.Client
doV4 bool
doV6 bool
doNetBIOS bool
unregisterPolicyChangeCb func() // called when the manager is closing
@@ -71,12 +74,16 @@ func NewOSConfigurator(logf logger.Logf, health *health.Tracker, bus *eventbus.B
wslManager: newWSLManager(logf, health),
}
if err := ret.checkInterfaces(); err != nil {
return nil, err
}
if isWindows10OrBetter() {
ret.nrptDB = newNRPTRuleDatabase(logf)
}
var err error
if ret.unregisterPolicyChangeCb, err = polc.RegisterChangeCallback(ret.sysPolicyChanged); err != nil {
if ret.unregisterPolicyChangeCb, err = polc.RegisterChangeCallback("", ret.sysPolicyChanged); err != nil {
logf("error registering policy change callback: %v", err) // non-fatal
}
@@ -92,22 +99,42 @@ func NewOSConfigurator(logf logger.Logf, health *health.Tracker, bus *eventbus.B
return ret, nil
}
func (m *windowsManager) openInterfaceKey(pfx winutil.RegistryPathPrefix) (registry.Key, error) {
var key registry.Key
var err error
path := pfx.WithSuffix(m.guid)
m.mu.Lock()
closing := m.closing
m.mu.Unlock()
if closing {
// Do not wait for the interface key to appear if the manager is being closed.
// If it's being closed due to the removal of the wintun adapter,
// the key would already be gone by now and will not reappear until tailscaled is restarted.
key, err = registry.OpenKey(registry.LOCAL_MACHINE, string(path), registry.SET_VALUE)
} else {
key, err = winutil.OpenKeyWait(registry.LOCAL_MACHINE, path, registry.SET_VALUE)
func (m *windowsManager) checkInterfaces() error {
guid, err := windows.GUIDFromString(m.guid)
if err != nil {
return err
}
luid, err := winipcfg.LUIDFromGUID(&guid)
if err != nil {
return err
}
_, err = luid.IPInterface(windows.AF_INET)
if err != nil && !errors.Is(err, windows.ERROR_NOT_FOUND) {
return fmt.Errorf("getting AF_INET interface: %w", err)
}
m.doV4 = err == nil
_, err = luid.IPInterface(windows.AF_INET6)
if err != nil && !errors.Is(err, windows.ERROR_NOT_FOUND) {
return fmt.Errorf("getting AF_INET6 interface: %w", err)
}
m.doV6 = err == nil
if !m.doV4 && !m.doV6 {
return errors.New("no IP interfaces available for dns configuration")
}
// NetBIOS is non-fatal
_, err = luid.IPInterface(windows.AF_NETBIOS)
m.doNetBIOS = err == nil
return nil
}
func (m *windowsManager) openInterfaceKey(pfx winutil.RegistryPathPrefix) (registry.Key, error) {
path := pfx.WithSuffix(m.guid)
key, err := registry.OpenKey(registry.LOCAL_MACHINE, string(path), registry.SET_VALUE)
if err != nil {
return 0, fmt.Errorf("opening %s: %w", path, err)
}
@@ -145,7 +172,7 @@ func (m *windowsManager) setSplitDNS(resolvers []netip.Addr, domains []dnsname.F
return fmt.Errorf("Split DNS unsupported on this Windows version")
}
defer m.nrptDB.Refresh()
defer m.nrptDB.NotifyPolicyChanged()
if len(resolvers) == 0 {
return m.nrptDB.DelAllRuleKeys()
}
@@ -245,7 +272,13 @@ func (m *windowsManager) setHosts(hosts []*HostEntry) error {
}
hostsFile := filepath.Join(systemDir, "drivers", "etc", "hosts")
b, err := os.ReadFile(hostsFile)
if err != nil {
switch {
case err == nil:
// Continue.
case errors.Is(err, fs.ErrNotExist):
// Non-fatal, we'll just create a new hosts file.
m.logf("failed to read the hosts file: %v", err)
default:
return err
}
outB, err := setTailscaleHosts(m.logf, b, hosts)
@@ -293,58 +326,66 @@ func (m *windowsManager) setPrimaryDNS(resolvers []netip.Addr, domains []dnsname
domStrs = append(domStrs, dom.WithoutTrailingDot())
}
key4, err := m.openInterfaceKey(winutil.IPv4TCPIPInterfacePrefix)
if err != nil {
return m.muteKeyNotFoundIfClosing(err)
}
defer key4.Close()
if m.doV4 {
key4, err := m.openInterfaceKey(winutil.IPv4TCPIPInterfacePrefix)
if err != nil {
return m.muteKeyNotFoundIfClosing(err)
}
defer key4.Close()
if len(ipsv4) == 0 {
if err := delValue(key4, "NameServer"); err != nil {
if len(ipsv4) == 0 {
if err := delValue(key4, "NameServer"); err != nil {
return err
}
} else if err := key4.SetStringValue("NameServer", strings.Join(ipsv4, ",")); err != nil {
return err
}
} else if err := key4.SetStringValue("NameServer", strings.Join(ipsv4, ",")); err != nil {
return err
}
if len(domains) == 0 {
if err := delValue(key4, "SearchList"); err != nil {
if len(domains) == 0 {
if err := delValue(key4, "SearchList"); err != nil {
return err
}
} else if err := key4.SetStringValue("SearchList", strings.Join(domStrs, ",")); err != nil {
return err
}
} else if err := key4.SetStringValue("SearchList", strings.Join(domStrs, ",")); err != nil {
return err
}
key6, err := m.openInterfaceKey(winutil.IPv6TCPIPInterfacePrefix)
if err != nil {
return m.muteKeyNotFoundIfClosing(err)
}
defer key6.Close()
if len(ipsv6) == 0 {
if err := delValue(key6, "NameServer"); err != nil {
// Disable LLMNR on the Tailscale interface. We don't do multicast, and we
// certainly don't do LLMNR, so it's pointless to make Windows try it. It is
// being deprecated.
if err := key4.SetDWordValue("EnableMulticast", 0); err != nil {
return err
}
} else if err := key6.SetStringValue("NameServer", strings.Join(ipsv6, ",")); err != nil {
return err
}
if len(domains) == 0 {
if err := delValue(key6, "SearchList"); err != nil {
if m.doV6 {
key6, err := m.openInterfaceKey(winutil.IPv6TCPIPInterfacePrefix)
if err != nil {
return m.muteKeyNotFoundIfClosing(err)
}
defer key6.Close()
if len(ipsv6) == 0 {
if err := delValue(key6, "NameServer"); err != nil {
return err
}
} else if err := key6.SetStringValue("NameServer", strings.Join(ipsv6, ",")); err != nil {
return err
}
} else if err := key6.SetStringValue("SearchList", strings.Join(domStrs, ",")); err != nil {
return err
}
// Disable LLMNR on the Tailscale interface. We don't do multicast, and we
// certainly don't do LLMNR, so it's pointless to make Windows try it. It is
// being deprecated.
if err := key4.SetDWordValue("EnableMulticast", 0); err != nil {
return err
}
if err := key6.SetDWordValue("EnableMulticast", 0); err != nil {
return err
if len(domains) == 0 {
if err := delValue(key6, "SearchList"); err != nil {
return err
}
} else if err := key6.SetStringValue("SearchList", strings.Join(domStrs, ",")); err != nil {
return err
}
// Disable LLMNR on the Tailscale interface. We don't do multicast, and we
// certainly don't do LLMNR, so it's pointless to make Windows try it. It is
// being deprecated.
if err := key6.SetDWordValue("EnableMulticast", 0); err != nil {
return err
}
}
return nil
@@ -354,6 +395,10 @@ func (m *windowsManager) disableLocalDNSOverrideViaNRPT() bool {
return m.knobs != nil && m.knobs.DisableLocalDNSOverrideViaNRPT.Load()
}
func (m *windowsManager) disableHostsFileUpdates() bool {
return m.knobs != nil && m.knobs.DisableHostsFileUpdates.Load()
}
func (m *windowsManager) SetDNS(cfg OSConfig) error {
// We can configure Windows DNS in one of two ways:
//
@@ -399,7 +444,15 @@ func (m *windowsManager) SetDNS(cfg OSConfig) error {
if err := m.setSplitDNS(resolvers, domains); err != nil {
return err
}
if err := m.setHosts(nil); err != nil {
var hosts []*HostEntry
if !m.disableHostsFileUpdates() && winenv.IsDomainJoined() {
// On domain-joined Windows devices the primary search domain (the one the device is joined to)
// always takes precedence over other search domains. This breaks MagicDNS when we are the primary
// resolver on the device (see #18712). To work around this Windows behavior, we should write MagicDNS
// host names the hosts file just as we do when we're not the primary resolver.
hosts = cfg.Hosts
}
if err := m.setHosts(hosts); err != nil {
return err
}
if err := m.setPrimaryDNS(cfg.Nameservers, cfg.SearchDomains); err != nil {
@@ -421,12 +474,14 @@ func (m *windowsManager) SetDNS(cfg OSConfig) error {
return err
}
// As we are not the primary resolver in this setup, we need to
// explicitly set some single name hosts to ensure that we can resolve
// them quickly and get around the 2.3s delay that otherwise occurs due
// to multicast timeouts.
if err := m.setHosts(cfg.Hosts); err != nil {
return err
if !m.disableHostsFileUpdates() {
// As we are not the primary resolver in this setup, we need to
// explicitly set some single name hosts to ensure that we can resolve
// them quickly and get around the 2.3s delay that otherwise occurs due
// to multicast timeouts.
if err := m.setHosts(cfg.Hosts); err != nil {
return err
}
}
}
@@ -436,40 +491,15 @@ func (m *windowsManager) SetDNS(cfg OSConfig) error {
// have changed, which makes the DNS settings actually take
// effect.
//
// This command can take a few seconds to run, so run it async, best effort.
//
// After re-registering DNS, also flush the DNS cache to clear out
// any cached split-horizon queries that are no longer the correct
// answer.
go func() {
t0 := time.Now()
m.logf("running ipconfig /registerdns ...")
cmd := exec.Command("ipconfig", "/registerdns")
cmd.SysProcAttr = &syscall.SysProcAttr{
CreationFlags: windows.DETACHED_PROCESS,
}
err := cmd.Run()
d := time.Since(t0).Round(time.Millisecond)
if err != nil {
m.logf("error running ipconfig /registerdns after %v: %v", d, err)
} else {
m.logf("ran ipconfig /registerdns in %v", d)
}
t0 = time.Now()
m.logf("running ipconfig /flushdns ...")
cmd = exec.Command("ipconfig", "/flushdns")
cmd.SysProcAttr = &syscall.SysProcAttr{
CreationFlags: windows.DETACHED_PROCESS,
}
err = cmd.Run()
d = time.Since(t0).Round(time.Millisecond)
if err != nil {
m.logf("error running ipconfig /flushdns after %v: %v", d, err)
} else {
m.logf("ran ipconfig /flushdns in %v", d)
}
}()
//
// Both operations are non-blocking and coalesced: at most one of
// each is in flight at a time, with at most one more queued.
m.logf("Re-registering DNS and flushing cache")
registerDNS()
Flush()
// On initial setup of WSL, the restart caused by --shutdown is slow,
// so we do it out-of-line.
@@ -551,9 +581,12 @@ func (m *windowsManager) reconfigureDNSRegistration() {
// the Windows DHCP client from registering Tailscale IP addresses with DNS
// and sending dynamic updates for our interface to AD domain controllers.
func (m *windowsManager) configureDNSRegistration(enabled bool) error {
prefixen := []winutil.RegistryPathPrefix{
winutil.IPv4TCPIPInterfacePrefix,
winutil.IPv6TCPIPInterfacePrefix,
prefixen := make([]winutil.RegistryPathPrefix, 0, 2)
if m.doV4 {
prefixen = append(prefixen, winutil.IPv4TCPIPInterfacePrefix)
}
if m.doV6 {
prefixen = append(prefixen, winutil.IPv6TCPIPInterfacePrefix)
}
var (
@@ -607,6 +640,10 @@ func (m *windowsManager) setSingleDWORD(prefix winutil.RegistryPathPrefix, value
// Further, LLMNR and NetBIOS are being deprecated anyway in favor of MDNS.
// https://techcommunity.microsoft.com/t5/networking-blog/aligning-on-mdns-ramping-down-netbios-name-resolution-and-llmnr/ba-p/3290816
func (m *windowsManager) disableNetBIOS() error {
if !m.doNetBIOS {
return nil
}
return m.setSingleDWORD(winutil.NetBTInterfacePrefix, "NetbiosOptions", 2)
}
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux && !android && !ts_omit_networkmanager
+6 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package dns
@@ -15,3 +15,8 @@ func (m noopManager) GetBaseConfig() (OSConfig, error) {
func NewNoopManager() (noopManager, error) {
return noopManager{}, nil
}
func isNoopManager(c OSConfigurator) bool {
_, ok := c.(noopManager)
return ok
}
+28 -34
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package dns
@@ -7,7 +7,6 @@ import (
"fmt"
"strings"
"sync"
"sync/atomic"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
@@ -19,7 +18,6 @@ import (
)
const (
dnsBaseGP = `SOFTWARE\Policies\Microsoft\Windows NT\DNSClient`
nrptBaseLocal = `SYSTEM\CurrentControlSet\Services\Dnscache\Parameters\DnsPolicyConfig`
nrptBaseGP = `SOFTWARE\Policies\Microsoft\Windows NT\DNSClient\DnsPolicyConfig`
@@ -53,13 +51,12 @@ const (
// nrptRuleDatabase encapsulates access to the Windows Name Resolution Policy
// Table (NRPT).
type nrptRuleDatabase struct {
logf logger.Logf
watcher *gp.ChangeWatcher
isGPRefreshPending atomic.Bool
mu sync.Mutex // protects the fields below
ruleIDs []string
isGPDirty bool
writeAsGP bool
logf logger.Logf
watcher *gp.ChangeWatcher
mu sync.Mutex // protects the fields below
ruleIDs []string
isGPDirty bool
writeAsGP bool
}
func newNRPTRuleDatabase(logf logger.Logf) *nrptRuleDatabase {
@@ -141,6 +138,12 @@ func (db *nrptRuleDatabase) detectWriteAsGP() {
writeAsGP = len(gpSubkeyMap) > 0
}
func regKeyHasNoValues(ki *registry.KeyInfo) bool {
// Either the key has no values, or there is one value: an empty default value.
return ki.ValueCount == 0 ||
ki.ValueCount == 1 && ki.MaxValueNameLen == 0 && ki.MaxValueLen == 0
}
// DelAllRuleKeys removes any and all NRPT rules that are owned by Tailscale.
func (db *nrptRuleDatabase) DelAllRuleKeys() error {
db.mu.Lock()
@@ -171,7 +174,7 @@ func (db *nrptRuleDatabase) delRuleKeys(nrptRuleIDs []string) error {
keyNameGP := nrptBaseGP + `\` + rid
err := registry.DeleteKey(registry.LOCAL_MACHINE, keyNameGP)
if err == nil {
// If this deleted subkey existed under the GP key, we will need to refresh.
// If this deleted subkey existed under the GP key, we will need to notify.
db.isGPDirty = true
} else if err != registry.ErrNotExist {
db.logf("Error deleting NRPT rule key %q: %v", keyNameGP, err)
@@ -193,8 +196,8 @@ func (db *nrptRuleDatabase) delRuleKeys(nrptRuleIDs []string) error {
return registry.DeleteKey(registry.LOCAL_MACHINE, nrptBaseGP)
}
// isPolicyConfigSubkeyEmpty returns true if and only if the nrptBaseGP exists
// and does not contain any values or subkeys.
// isPolicyConfigSubkeyEmpty returns whether the nrptBaseGP exists and does not
// contain any values or subkeys.
func isPolicyConfigSubkeyEmpty() (bool, error) {
subKey, err := registry.OpenKey(registry.LOCAL_MACHINE, nrptBaseGP, registry.READ)
if err != nil {
@@ -210,7 +213,7 @@ func isPolicyConfigSubkeyEmpty() (bool, error) {
return false, err
}
return (ki.ValueCount == 0 && ki.SubKeyCount == 0), nil
return regKeyHasNoValues(ki) && ki.SubKeyCount == 0, nil
}
func (db *nrptRuleDatabase) WriteSplitDNSConfig(servers []string, domains []dnsname.FQDN) error {
@@ -277,26 +280,22 @@ func (db *nrptRuleDatabase) WriteSplitDNSConfig(servers []string, domains []dnsn
return nil
}
// Refresh notifies the Windows group policy engine when policies have changed.
func (db *nrptRuleDatabase) Refresh() {
// NotifyPolicyChanged notifies the Windows group policy engine when policies have changed.
func (db *nrptRuleDatabase) NotifyPolicyChanged() {
db.mu.Lock()
defer db.mu.Unlock()
db.refreshLocked()
db.notifyPolicyChangedLocked()
}
func (db *nrptRuleDatabase) refreshLocked() {
func (db *nrptRuleDatabase) notifyPolicyChangedLocked() {
if !db.isGPDirty {
return
}
// Record that we are about to initiate a refresh.
// (*nrptRuleDatabase).watchForGPChanges() checks this value to avoid false
// positives.
db.isGPRefreshPending.Store(true)
if err := gp.RefreshMachinePolicy(true); err != nil {
db.logf("RefreshMachinePolicy failed: %v", err)
db.logf("Calling NotifyMachinePolicyChange")
if err := gp.NotifyMachinePolicyChange(); err != nil {
db.logf("NotifyMachinePolicyChange failed: %v", err)
return
}
@@ -318,7 +317,7 @@ func (db *nrptRuleDatabase) writeNRPTRule(ruleID string, servers, doms []string)
}
defer key.Close()
if err := writeNRPTValues(key, strings.Join(servers, "; "), doms); err != nil {
if err := writeNRPTValues(key, strings.Join(servers, ";"), doms); err != nil {
return err
}
}
@@ -355,12 +354,7 @@ func writeNRPTValues(key registry.Key, servers string, doms []string) error {
func (db *nrptRuleDatabase) watchForGPChanges() {
watchHandler := func() {
// Do not invoke detectWriteAsGP when we ourselves were responsible for
// initiating the group policy refresh.
if db.isGPRefreshPending.CompareAndSwap(true, false) {
return
}
db.logf("Computer group policies refreshed, reconfiguring NRPT rule database.")
db.logf("Received machine group policy change notification. Reconfiguring NRPT rule database.")
db.detectWriteAsGP()
}
@@ -380,8 +374,8 @@ func (db *nrptRuleDatabase) watchForGPChanges() {
// db.mu must already be locked.
func (db *nrptRuleDatabase) updateGroupPoliciesLocked(writeAsGP bool) {
// Since we're updating the group policy NRPT table, we need
// to refresh once this updateGroupPoliciesLocked is done.
defer db.refreshLocked()
// to notify once this updateGroupPoliciesLocked is done.
defer db.notifyPolicyChangedLocked()
for _, id := range db.ruleIDs {
if writeAsGP {
+70 -15
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build (linux && !android) || freebsd || openbsd
@@ -7,10 +7,14 @@ package dns
import (
"bytes"
"errors"
"fmt"
"net/netip"
"os/exec"
"slices"
"strings"
"tailscale.com/net/tsaddr"
"tailscale.com/types/logger"
)
@@ -39,6 +43,38 @@ func (m openresolvManager) logCmdErr(cmd *exec.Cmd, err error) {
m.logf("error running command %s stderr=%q exitCode=%d: %v", commandStr, exerr.Stderr, exerr.ExitCode(), err)
}
// openresolvNoSnippetsExitCode is the exit status resolvconf returns when a
// requested config snippet does not exist. Asking for all snippets when none
// are registered returns this status too, because openresolv treats the empty
// result as a missing snippet rather than as an empty list.
const openresolvNoSnippetsExitCode = 2
// readSnippets runs resolvconf with the given arguments and returns its stdout.
// An exit status of openresolvNoSnippetsExitCode is not an error: it returns no
// output and a nil error. Other failures are logged and returned.
//
// Callers must pass either "-i" with no arguments or "-l" with explicit snippet
// names. Only those forms produce empty stdout alongside
// openresolvNoSnippetsExitCode; "-i" with snippet names prints the ones that do
// exist and still exits 2, so its output would be silently dropped.
//
// Stderr is excluded from the returned bytes so that diagnostics like "No
// resolv.conf for key foo" are never parsed as snippet names or resolv.conf
// lines. logCmdErr still logs stderr when a command fails.
func (m openresolvManager) readSnippets(args ...string) ([]byte, error) {
cmd := exec.Command("resolvconf", args...)
out, err := cmd.Output()
if err != nil {
if ee, ok := errors.AsType[*exec.ExitError](err); ok && ee.ExitCode() == openresolvNoSnippetsExitCode {
m.logf("[v1] resolvconf %q found no matching config snippets", args)
return nil, nil
}
m.logCmdErr(cmd, err)
return nil, err
}
return out, nil
}
func (m openresolvManager) deleteTailscaleConfig() error {
cmd := exec.Command("resolvconf", "-f", "-d", "tailscale")
out, err := cmd.CombinedOutput()
@@ -75,18 +111,21 @@ func (m openresolvManager) GetBaseConfig() (OSConfig, error) {
// List the names of all config snippets openresolv is aware
// of. Snippets get listed in priority order (most to least),
// which we'll exploit later.
bs, err := exec.Command("resolvconf", "-i").CombinedOutput()
bs, err := m.readSnippets("-i")
if err != nil {
return OSConfig{}, err
}
// Remove the "tailscale" snippet from the list.
args := []string{"-l"}
for _, f := range strings.Split(strings.TrimSpace(string(bs)), " ") {
if f == "tailscale" {
continue
}
args = append(args, f)
others := slices.DeleteFunc(strings.Fields(string(bs)), func(f string) bool {
return f == "tailscale"
})
if len(others) == 0 {
// There are no other snippets, so there is no base config to read.
// Returning early is required, not merely an optimization: a
// "resolvconf -l" with no snippet names lists every snippet,
// including Tailscale's own, which would make quad-100 its own
// upstream. See tailscale/tailscale#20825.
return OSConfig{}, nil
}
// List all resolvconf snippets except our own, and parse that as
@@ -100,14 +139,30 @@ func (m openresolvManager) GetBaseConfig() (OSConfig, error) {
// practice, openresolv uses are generally quite limited, and boil
// down to 1-2 DHCP leases, for which the correct outcome is a
// blended config like the one we produce here.
var buf bytes.Buffer
cmd := exec.Command("resolvconf", args...)
cmd.Stdout = &buf
if err := cmd.Run(); err != nil {
m.logCmdErr(cmd, err)
out, err := m.readSnippets(append([]string{"-l"}, others...)...)
if err != nil {
return OSConfig{}, err
}
return readResolv(&buf)
cfg, err := readResolv(bytes.NewReader(out))
if err != nil {
return OSConfig{}, err
}
// Forwarding to the Tailscale service IPs would make quad-100 send
// queries to itself, in an infinite loop, so drop them if another
// snippet names them. See tailscale/tailscale#7816.
var removed bool
cfg.Nameservers = slices.DeleteFunc(cfg.Nameservers, func(ip netip.Addr) bool {
if ip == tsaddr.TailscaleServiceIP() || ip == tsaddr.TailscaleServiceIPv6() {
removed = true
return true
}
return false
})
if removed {
m.logf("[v1] dropped Tailscale service IP from openresolv base config")
}
return cfg, nil
}
func (m openresolvManager) Close() error {
+21 -3
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package dns
@@ -12,6 +12,7 @@ import (
"strings"
"tailscale.com/feature/buildfeatures"
"tailscale.com/types/dnstype"
"tailscale.com/types/logger"
"tailscale.com/util/dnsname"
)
@@ -36,7 +37,9 @@ type OSConfigurator interface {
// Only works when SupportsSplitDNS=false.
// Implementations that don't support getting the base config must
// return ErrGetBaseConfigNotSupported.
// return ErrGetBaseConfigNotSupported. Implementations that can read the
// configuration but cannot recover any underlying resolvers may return
// ErrGetBaseConfigNoResolvers; other read failures should return their error.
GetBaseConfig() (OSConfig, error)
// Close removes Tailscale-related DNS configuration from the OS.
Close() error
@@ -66,6 +69,15 @@ type OSConfig struct {
// from the OS, which will only work with OSConfigurators that
// report SupportsSplitDNS()=true.
MatchDomains []dnsname.FQDN
// Resolvers is the full-fidelity form of Nameservers, populated only by
// OSConfigurators whose [OSConfigurator.GetBaseConfig] can recover more
// of the underlying resolver configuration than plain IP:53 addresses
// (non-standard ports, DoH/DoT endpoints). It is input to the DNS
// manager only: the manager blends it into quad-100's catch-all route
// when the tunnel resolver must serve as the OS primary. It is never
// applied to the OS itself, and [OSConfig.Equal] deliberately ignores
// it, because it does not affect the configuration installed on the OS.
Resolvers []*dnstype.Resolver `json:",omitempty"`
}
func (o *OSConfig) WriteToBufioWriter(w *bufio.Writer) {
@@ -108,7 +120,8 @@ func (o OSConfig) IsZero() bool {
return len(o.Hosts) == 0 &&
len(o.Nameservers) == 0 &&
len(o.SearchDomains) == 0 &&
len(o.MatchDomains) == 0
len(o.MatchDomains) == 0 &&
len(o.Resolvers) == 0
}
func (a OSConfig) Equal(b OSConfig) bool {
@@ -199,3 +212,8 @@ func (a OSConfig) Format(f fmt.State, verb rune) {
// OSConfigurator.GetBaseConfig returns when the OSConfigurator
// doesn't support reading the underlying configuration out of the OS.
var ErrGetBaseConfigNotSupported = errors.New("getting OS base config is not supported")
// ErrGetBaseConfigNoResolvers is returned by [OSConfigurator.GetBaseConfig]
// when neither a fresh read nor a retained configuration supplies an underlying
// resolver. It produces the same health warning as a successful but empty read.
var ErrGetBaseConfigNoResolvers = errEmptyBaseConfig
+73 -34
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package publicdns contains mapping and helpers for working with
@@ -7,18 +7,16 @@ package publicdns
import (
"bytes"
"encoding/binary"
"encoding/hex"
"fmt"
"log"
"math/big"
"net/netip"
"slices"
"sort"
"strconv"
"strings"
"sync"
"tailscale.com/feature/buildfeatures"
"tailscale.com/util/testenv"
)
// dohOfIP maps from public DNS IPs to their DoH base URL.
@@ -58,12 +56,15 @@ func DoHEndpointFromIP(ip netip.Addr) (dohBase string, dohOnly bool, ok bool) {
return sb.String(), true, true
}
// Control D DoH URLs are of the form "https://dns.controld.com/8yezwenugs"
// where the path component is represented by 8 bytes (7-14) of the IPv6 address in base36
if controlDv6RangeA.Contains(ip) || controlDv6RangeB.Contains(ip) {
path := big.NewInt(0).SetBytes(ip.AsSlice()[6:14]).Text(36)
return controlDBase + path, true, true
}
// Control D's free anycast resolvers (freedns.controld.com/pN) are the only
// Control D addresses that serve DoH; they're registered as exact entries in
// dohOfIP (see populate) and are handled by the lookup above.
//
// The ID-encoded addresses in the 2606:1a40::/48 ranges (customer resolver ID
// base36-encoded into the address) are legacy plaintext-DNS (port 53) endpoints
// that refuse DoH on :443, so we deliberately don't upgrade them to DoH here:
// they fall through and are used as ordinary port-53 resolvers. Premium DoH is
// only reachable via the dns.controld.com/<id> URL path (see DoHIPsOfBase).
return "", false, false
}
@@ -124,15 +125,12 @@ func DoHIPsOfBase(dohBase string) []netip.Addr {
}
}
}
if pathStr, ok := strings.CutPrefix(dohBase, controlDBase); ok {
if i := strings.IndexFunc(pathStr, isSlashOrQuestionMark); i != -1 {
pathStr = pathStr[:i]
}
if strings.HasPrefix(dohBase, controlDBase) {
return []netip.Addr{
controlDv4One,
controlDv4Two,
controlDv6Gen(controlDv6RangeA.Addr(), pathStr),
controlDv6Gen(controlDv6RangeB.Addr(), pathStr),
controlDv6One,
controlDv6Two,
}
}
return nil
@@ -275,6 +273,26 @@ func populate() {
addDoH("76.76.10.4", "https://freedns.controld.com/family")
addDoH("2606:1a40::4", "https://freedns.controld.com/family")
addDoH("2606:1a40:1::4", "https://freedns.controld.com/family")
// CIRA Canadian Shield: https://www.cira.ca/en/canadian-shield/configure/summary-cira-canadian-shield-dns-resolver-addresses/
// CIRA Canadian Shield Private (DNS resolution only)
addDoH("149.112.121.10", "https://private.canadianshield.cira.ca/dns-query")
addDoH("149.112.122.10", "https://private.canadianshield.cira.ca/dns-query")
addDoH("2620:10a:80bb::10", "https://private.canadianshield.cira.ca/dns-query")
addDoH("2620:10a:80bc::10", "https://private.canadianshield.cira.ca/dns-query")
// CIRA Canadian Shield Protected (Malware and phishing protection)
addDoH("149.112.121.20", "https://protected.canadianshield.cira.ca/dns-query")
addDoH("149.112.122.20", "https://protected.canadianshield.cira.ca/dns-query")
addDoH("2620:10a:80bb::20", "https://protected.canadianshield.cira.ca/dns-query")
addDoH("2620:10a:80bc::20", "https://protected.canadianshield.cira.ca/dns-query")
// CIRA Canadian Shield Family (Protected + blocking adult content)
addDoH("149.112.121.30", "https://family.canadianshield.cira.ca/dns-query")
addDoH("149.112.122.30", "https://family.canadianshield.cira.ca/dns-query")
addDoH("2620:10a:80bb::30", "https://family.canadianshield.cira.ca/dns-query")
addDoH("2620:10a:80bc::30", "https://family.canadianshield.cira.ca/dns-query")
}
var (
@@ -308,6 +326,8 @@ var (
controlDv6RangeB = netip.MustParsePrefix("2606:1a40:1::/48")
controlDv4One = netip.MustParseAddr("76.76.2.22")
controlDv4Two = netip.MustParseAddr("76.76.10.22")
controlDv6One = netip.MustParseAddr("2606:1a40::22")
controlDv6Two = netip.MustParseAddr("2606:1a40:1::22")
)
// nextDNSv6Gen generates a NextDNS IPv6 address from the upper 8 bytes in the
@@ -321,23 +341,6 @@ func nextDNSv6Gen(ip netip.Addr, id []byte) netip.Addr {
return netip.AddrFrom16(a)
}
// controlDv6Gen generates a Control D IPv6 address from provided ip and id.
//
// The id is taken from the DoH query path component and represents a unique resolver configuration.
// e.g. https://dns.controld.com/hyq3ipr2ct
func controlDv6Gen(ip netip.Addr, id string) netip.Addr {
b := make([]byte, 8)
decoded, err := strconv.ParseUint(id, 36, 64)
if err != nil {
log.Printf("controlDv6Gen: failed to parse id %q: %v", id, err)
}
binary.BigEndian.PutUint64(b, decoded)
a := ip.AsSlice()
copy(a[6:14], b)
addr, _ := netip.AddrFromSlice(a)
return addr
}
// IPIsDoHOnlyServer reports whether ip is a DNS server that should only use
// DNS-over-HTTPS (not regular port 53 DNS).
func IPIsDoHOnlyServer(ip netip.Addr) bool {
@@ -347,3 +350,39 @@ func IPIsDoHOnlyServer(ip netip.Addr) bool {
controlDv6RangeA.Contains(ip) || controlDv6RangeB.Contains(ip) ||
ip == controlDv4One || ip == controlDv4Two
}
var testMu sync.Mutex
// RegisterTestDoHEndpoint registers a test DoH endpoint mapping for use in tests.
// It maps the given IP to the DoH base URL, and the URL back to the IP.
//
// This function panics if called outside of tests, and cannot be called
// concurrently with any usage of this package (i.e. before any DNS forwarders
// are created). It is safe to call concurrently with itself.
//
// It returns a cleanup function that removes the registration.
func RegisterTestDoHEndpoint(ip netip.Addr, dohBase string) func() {
if !testenv.InTest() {
panic("RegisterTestDoHEndpoint called outside of tests")
}
populateOnce.Do(populate)
testMu.Lock()
defer testMu.Unlock()
dohOfIP[ip] = dohBase
dohIPsOfBase[dohBase] = append(dohIPsOfBase[dohBase], ip)
return func() {
testMu.Lock()
defer testMu.Unlock()
delete(dohOfIP, ip)
dohIPsOfBase[dohBase] = slices.DeleteFunc(dohIPsOfBase[dohBase], func(addr netip.Addr) bool {
return addr == ip
})
if len(dohIPsOfBase[dohBase]) == 0 {
delete(dohIPsOfBase, dohBase)
}
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
#!/bin/sh
# Copyright (c) Tailscale Inc & AUTHORS
# Copyright (c) Tailscale Inc & contributors
# SPDX-License-Identifier: BSD-3-Clause
#
# This script is a workaround for a vpn-unfriendly behavior of the
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux || freebsd || openbsd
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package resolvconffile parses & serializes /etc/resolv.conf-style files.
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !gokrazy
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build gokrazy
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build openbsd
+31 -21
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux && !android && !ts_omit_resolved
@@ -215,29 +215,10 @@ func (m *resolvedManager) run(ctx context.Context) {
}
continue
}
// In theory the signal was filtered by DBus, but if
// AddMatchSignal in the constructor failed, we may be
// getting other spam.
if signal.Path != dbusPath || signal.Name != dbusInterface+"."+dbusOwnerSignal {
continue
}
if lastConfig.IsZero() {
continue
}
// signal.Body is a []any of 3 strings: bus name, previous owner, new owner.
if len(signal.Body) != 3 {
m.logf("[unexpected] DBus NameOwnerChanged len(Body) = %d, want 3")
}
if name, ok := signal.Body[0].(string); !ok || name != dbusResolvedObject {
continue
}
newOwner, ok := signal.Body[2].(string)
if !ok {
m.logf("[unexpected] DBus NameOwnerChanged.new_owner is a %T, not a string", signal.Body[2])
}
if newOwner == "" {
// systemd-resolved left the bus, no current owner,
// nothing to do.
if !m.isResolvedRestartSignal(signal) {
continue
}
// The resolved bus name has a new owner, meaning resolved
@@ -258,6 +239,35 @@ func (m *resolvedManager) run(ctx context.Context) {
}
}
// isResolvedRestartSignal reports whether signal says that systemd-resolved
// has acquired a new D-Bus owner.
func (m *resolvedManager) isResolvedRestartSignal(signal *dbus.Signal) bool {
// In theory the signal was filtered by DBus, but if AddMatchSignal in the
// constructor failed, we may be getting other spam.
if signal == nil {
m.logf("[unexpected] received nil DBus signal")
return false
}
if signal.Path != dbusPath || signal.Name != dbusInterface+"."+dbusOwnerSignal {
return false
}
// signal.Body is a []any of 3 strings: bus name, previous owner, new owner.
if len(signal.Body) != 3 {
m.logf("[unexpected] DBus NameOwnerChanged len(Body) = %d, want 3", len(signal.Body))
return false
}
if name, ok := signal.Body[0].(string); !ok || name != dbusResolvedObject {
return false
}
newOwner, ok := signal.Body[2].(string)
if !ok {
m.logf("[unexpected] DBus NameOwnerChanged.new_owner is a %T, not a string", signal.Body[2])
return false
}
return newOwner != ""
}
// setConfigOverDBus updates resolved DBus config and is only called from the run goroutine.
func (m *resolvedManager) setConfigOverDBus(ctx context.Context, rManager dbus.BusObject, config OSConfig) error {
ctx, cancel := context.WithTimeout(ctx, reconfigTimeout)
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package resolver
+497 -116
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package resolver
@@ -13,6 +13,7 @@ import (
"errors"
"fmt"
"io"
"maps"
"net"
"net/http"
"net/netip"
@@ -41,8 +42,10 @@ import (
"tailscale.com/types/dnstype"
"tailscale.com/types/logger"
"tailscale.com/types/nettype"
"tailscale.com/types/views"
"tailscale.com/util/cloudenv"
"tailscale.com/util/dnsname"
"tailscale.com/util/mak"
"tailscale.com/util/race"
"tailscale.com/version"
)
@@ -63,6 +66,17 @@ func truncatedFlagSet(pkt []byte) bool {
return (binary.BigEndian.Uint16(pkt[2:4]) & dnsFlagTruncated) != 0
}
// setTCFlag sets the TC (truncated) flag in the DNS packet header.
// The packet must be at least headerBytes in length.
func setTCFlag(packet []byte) {
if len(packet) < headerBytes {
return
}
flags := binary.BigEndian.Uint16(packet[2:4])
flags |= dnsFlagTruncated
binary.BigEndian.PutUint16(packet[2:4], flags)
}
const (
// dohIdleConnTimeout is how long to keep idle HTTP connections
// open to DNS-over-HTTPS servers. 10 seconds is a sensible
@@ -131,47 +145,59 @@ func getRCode(packet []byte) dns.RCode {
return dns.RCode(packet[3] & 0x0F)
}
// findOPTRecord finds and validates the EDNS OPT record at the end of a DNS packet.
// Returns the requested buffer size and a pointer to the OPT record bytes if valid,
// or (0, nil) if no valid OPT record is found.
// The OPT record must be at the very end of the packet with no option codes.
func findOPTRecord(packet []byte) (requestedSize uint16, opt []byte) {
const optFixedBytes = 11 // size of an OPT record with no option codes
const edns0Version = 0 // EDNS version number (currently only version 0 is defined)
if len(packet) < headerBytes+optFixedBytes {
return 0, nil
}
arCount := binary.BigEndian.Uint16(packet[10:12])
if arCount == 0 {
// OPT shows up in an AR, so there must be no OPT
return 0, nil
}
// https://datatracker.ietf.org/doc/html/rfc6891#section-6.1.2
opt = packet[len(packet)-optFixedBytes:]
if opt[0] != 0 {
// OPT NAME must be 0 (root domain)
return 0, nil
}
if dns.Type(binary.BigEndian.Uint16(opt[1:3])) != dns.TypeOPT {
// Not an OPT record
return 0, nil
}
requestedSize = binary.BigEndian.Uint16(opt[3:5])
// Ignore extended RCODE in opt[5]
if opt[6] != edns0Version {
// Be conservative and don't touch unknown versions.
return 0, nil
}
// Ignore flags in opt[6:9]
if binary.BigEndian.Uint16(opt[9:11]) != 0 {
// RDLEN must be 0 (no variable length data). We're at the end of the
// packet so this should be 0 anyway.
return 0, nil
}
return requestedSize, opt
}
// clampEDNSSize attempts to limit the maximum EDNS response size. This is not
// an exhaustive solution, instead only easy cases are currently handled in the
// interest of speed and reduced complexity. Only OPT records at the very end of
// the message with no option codes are addressed.
// TODO: handle more situations if we discover that they happen often
func clampEDNSSize(packet []byte, maxSize uint16) {
// optFixedBytes is the size of an OPT record with no option codes.
const optFixedBytes = 11
const edns0Version = 0
if len(packet) < headerBytes+optFixedBytes {
return
}
arCount := binary.BigEndian.Uint16(packet[10:12])
if arCount == 0 {
// OPT shows up in an AR, so there must be no OPT
return
}
// https://datatracker.ietf.org/doc/html/rfc6891#section-6.1.2
opt := packet[len(packet)-optFixedBytes:]
if opt[0] != 0 {
// OPT NAME must be 0 (root domain)
return
}
if dns.Type(binary.BigEndian.Uint16(opt[1:3])) != dns.TypeOPT {
// Not an OPT record
return
}
requestedSize := binary.BigEndian.Uint16(opt[3:5])
// Ignore extended RCODE in opt[5]
if opt[6] != edns0Version {
// Be conservative and don't touch unknown versions.
return
}
// Ignore flags in opt[6:9]
if binary.BigEndian.Uint16(opt[9:11]) != 0 {
// RDLEN must be 0 (no variable length data). We're at the end of the
// packet so this should be 0 anyway)..
requestedSize, opt := findOPTRecord(packet)
if opt == nil {
return
}
@@ -183,6 +209,57 @@ func clampEDNSSize(packet []byte, maxSize uint16) {
binary.BigEndian.PutUint16(opt[3:5], maxSize)
}
// getEDNSBufferSize extracts the EDNS buffer size from a DNS request packet.
// Returns (bufferSize, true) if a valid EDNS OPT record is found,
// or (0, false) if no EDNS OPT record is found or if there's an error.
func getEDNSBufferSize(packet []byte) (uint16, bool) {
requestedSize, opt := findOPTRecord(packet)
return requestedSize, opt != nil
}
// checkResponseSizeAndSetTC sets the TC (truncated) flag in the DNS header when
// the response exceeds the maximum UDP size. If no EDNS OPT record is present
// in the request, it sets the TC flag when the response is bigger than 512 bytes
// per RFC 1035. If an EDNS OPT record is present, it sets the TC flag when the
// response is bigger than the EDNS buffer size. The response buffer is not
// truncated; only the TC flag is set. Returns the response unchanged except for
// the TC flag being set if needed.
func checkResponseSizeAndSetTC(response []byte, request []byte, family string, logf logger.Logf) []byte {
const defaultUDPSize = 512 // default maximum UDP DNS packet size per RFC 1035
// Only check for UDP queries; TCP can handle larger responses
if family != "udp" {
return response
}
// Check if TC flag is already set
if len(response) < headerBytes {
return response
}
if truncatedFlagSet(response) {
// TC flag already set, nothing to do
return response
}
ednsSize, hasEDNS := getEDNSBufferSize(request)
// Determine maximum allowed size
var maxSize int
if hasEDNS {
maxSize = int(ednsSize)
} else {
// No EDNS: enforce default UDP size limit per RFC 1035
maxSize = defaultUDPSize
}
// Check if response exceeds maximum size
if len(response) > maxSize {
setTCFlag(response)
}
return response
}
// dnsForwarderFailing should be raised when the forwarder is unable to reach the
// upstream resolvers. This is a high severity warning as it results in "no internet".
// This warning must be cleared when the forwarder is working again.
@@ -249,6 +326,30 @@ type forwarder struct {
// /etc/resolv.conf is missing/corrupt, and the peerapi ExitDNS stub
// resolver lookup.
cloudHostFallback []resolverAndDelay
// schemes are the collection of registered URI scheme names that
// dynamically decide which resolver to use at the time of each query. The
// key is the scheme (the portion before the first `:`) and the value is a
// handler that determines where the current query should be sent.
// Use schemeCacheLocked() to get the current contents that can continue to
// be accessed once mu is released. This allows the (much more common)
// resolver code path to avoid repeated locking and unlocking.
// When modified, call invalidateSchemeCacheLocked() before unlocking mu.
schemes map[string]CustomSchemeHandler
// schemeCache is an immutable copy of schemes. Do not read directly,
// use schemeCacheLocked() which will regenerate its contents as needed.
schemeCache views.Map[string, CustomSchemeHandler]
// acceptDNS tracks the CorpDNS pref (--accept-dns)
// This lets us skip health warnings if the forwarder receives inbound
// queries directly - but we didn't configure it with any upstream resolvers.
// That's an error, but not a health error if the user has disabled CorpDNS.
acceptDNS bool
}
func (f *forwarder) probeLocks() {
f.mu.Lock()
f.mu.Unlock()
}
func newForwarder(logf logger.Logf, netMon *netmon.Monitor, linkSel ForwardLinkSelector, dialer *tsdial.Dialer, health *health.Tracker, knobs *controlknobs.Knobs) *forwarder {
@@ -360,7 +461,7 @@ func cloudResolvers() []resolverAndDelay {
// Resolver.SetConfig on reconfig.
//
// The memory referenced by routesBySuffix should not be modified.
func (f *forwarder) setRoutes(routesBySuffix map[dnsname.FQDN][]*dnstype.Resolver) {
func (f *forwarder) setRoutes(routesBySuffix map[dnsname.FQDN][]*dnstype.Resolver, acceptDNS bool) {
routes := make([]route, 0, len(routesBySuffix))
cloudHostFallback := cloudResolvers()
@@ -394,6 +495,7 @@ func (f *forwarder) setRoutes(routesBySuffix map[dnsname.FQDN][]*dnstype.Resolve
f.mu.Lock()
defer f.mu.Unlock()
f.acceptDNS = acceptDNS
f.routes = routes
f.cloudHostFallback = cloudHostFallback
}
@@ -430,6 +532,62 @@ func (f *forwarder) getKnownDoHClientForProvider(urlBase string) (c *http.Client
if len(allIPs) == 0 {
return nil, false
}
return f.newDoHClientLocked(urlBase, urlBase, allIPs)
}
// getDoHClientForResolver returns an HTTP client for the DoH server described
// by r. Known public providers ([publicdns.DoHIPsOfBase]) work as before. For
// arbitrary providers — typically enterprise resolvers recovered from the
// OS's base configuration — the resolver must either carry a
// BootstrapResolution, or its URL host must be an IP literal (which needs no
// bootstrap): we cannot resolve the DoH server's own name through DNS without
// recursing through ourselves.
func (f *forwarder) getDoHClientForResolver(r *dnstype.Resolver) (c *http.Client, ok bool) {
urlBase := r.Addr
f.mu.Lock()
defer f.mu.Unlock()
key := dohClientCacheKey(urlBase, r.BootstrapResolution)
if c, ok := f.dohClient[key]; ok {
return c, true
}
allIPs := publicdns.DoHIPsOfBase(urlBase)
if len(allIPs) == 0 {
if len(r.BootstrapResolution) > 0 {
allIPs = r.BootstrapResolution
} else {
// A URL whose host is an IP literal dials itself; no bootstrap
// resolution is needed.
u, err := url.Parse(urlBase)
if err != nil {
return nil, false
}
ip, err := netip.ParseAddr(u.Hostname())
if err != nil {
return nil, false
}
allIPs = []netip.Addr{ip}
}
}
return f.newDoHClientLocked(key, urlBase, allIPs)
}
// dohClientCacheKey distinguishes cached clients: bootstrap results can change
// with the underlying network, unlike known public provider IPs.
func dohClientCacheKey(urlBase string, bootstrap []netip.Addr) string {
if len(bootstrap) == 0 || len(publicdns.DoHIPsOfBase(urlBase)) > 0 {
return urlBase
}
ips := make([]string, len(bootstrap))
for i, ip := range bootstrap {
ips[i] = ip.String()
}
return urlBase + "\x00" + strings.Join(ips, ",")
}
// newDoHClientLocked builds a DoH HTTP client that dials urlBase's host at
// the given IPs, caching the client under cacheKey (which differs from
// urlBase when bootstrap results vary). f.mu must be held.
func (f *forwarder) newDoHClientLocked(cacheKey, urlBase string, allIPs []netip.Addr) (c *http.Client, ok bool) {
dohURL, err := url.Parse(urlBase)
if err != nil {
return nil, false
@@ -465,7 +623,7 @@ func (f *forwarder) getKnownDoHClientForProvider(urlBase string) (c *http.Client
if f.dohClient == nil {
f.dohClient = map[string]*http.Client{}
}
f.dohClient[urlBase] = c
f.dohClient[cacheKey] = c
return c, true
}
@@ -535,21 +693,32 @@ func (f *forwarder) send(ctx context.Context, fq *forwardQuery, rr resolverAndDe
if !buildfeatures.HasPeerAPIClient {
return nil, feature.ErrUnavailable
}
return f.sendDoH(ctx, rr.name.Addr, f.dialer.PeerAPIHTTPClient(), fq.packet)
res, err := f.sendDoH(ctx, rr.name.Addr, f.dialer.PeerAPIHTTPClient(), fq.packet)
if err != nil {
return nil, err
}
// Check response size and set TC flag if needed (only for UDP queries)
res = checkResponseSizeAndSetTC(res, fq.packet, fq.family, f.logf)
return res, nil
}
if strings.HasPrefix(rr.name.Addr, "https://") {
// Only known DoH providers are supported currently. Specifically, we
// only support DoH providers where we can TCP connect to them on port
// 443 at the same IP address they serve normal UDP DNS from (1.1.1.1,
// 8.8.8.8, 9.9.9.9, etc.) That's why OpenDNS and custom DoH providers
// aren't currently supported. There's no backup DNS resolution path for
// them.
urlBase := rr.name.Addr
if hc, ok := f.getKnownDoHClientForProvider(urlBase); ok {
return f.sendDoH(ctx, urlBase, hc, fq.packet)
// Known DoH providers (see tailscale.com/net/dns/publicdns) are dialed
// at their well-known IPs. Arbitrary providers — typically enterprise
// resolvers recovered from the OS's base DNS configuration — are only
// usable when the resolver carries a bootstrap resolution, or when the
// URL's host is an IP literal, since we cannot resolve the DoH
// server's own name through DNS without recursing through ourselves.
if hc, ok := f.getDoHClientForResolver(rr.name); ok {
res, err := f.sendDoH(ctx, rr.name.Addr, hc, fq.packet)
if err != nil {
return nil, err
}
// Check response size and set TC flag if needed (only for UDP queries)
res = checkResponseSizeAndSetTC(res, fq.packet, fq.family, f.logf)
return res, nil
}
metricDNSFwdErrorType.Add(1)
return nil, fmt.Errorf("arbitrary https:// resolvers not supported yet")
return nil, fmt.Errorf("cannot dial https:// resolver %q: no known IPs or bootstrap resolution", rr.name.Addr)
}
if strings.HasPrefix(rr.name.Addr, "tls://") {
metricDNSFwdErrorType.Add(1)
@@ -634,8 +803,7 @@ func (f *forwarder) send(ctx context.Context, fq *forwardQuery, rr resolverAndDe
}
// If we got a truncated UDP response, return that instead of an error.
var trErr truncatedResponseError
if errors.As(err, &trErr) {
if trErr, ok := errors.AsType[truncatedResponseError](err); ok {
return trErr.res, nil
}
return nil, err
@@ -647,6 +815,27 @@ type truncatedResponseError struct {
func (tr truncatedResponseError) Error() string { return "response truncated" }
// rcodeResponseError is returned when an upstream DNS server responds with an
// rcode that is treated as a soft error (currently REFUSED and SERVFAIL). The
// response bytes are preserved so they can be returned to the client rather
// than synthesizing a new response.
type rcodeResponseError struct {
rcode dns.RCode
res []byte
}
func (r rcodeResponseError) Error() string { return r.Unwrap().Error() }
func (r rcodeResponseError) Unwrap() error {
switch r.rcode {
case dns.RCodeRefused:
return errRefused
case dns.RCodeServerFailure:
return errServerFailure
}
return nil
}
var errRefused = errors.New("response code indicates refusal")
var errServerFailure = errors.New("response code indicates server issue")
var errTxIDMismatch = errors.New("txid doesn't match")
@@ -659,21 +848,10 @@ func (f *forwarder) sendUDP(ctx context.Context, fq *forwardQuery, rr resolverAn
metricDNSFwdUDP.Add(1)
ctx = sockstats.WithSockStats(ctx, sockstats.LabelDNSForwarderUDP, f.logf)
ln, err := f.packetListener(ipp.Addr())
conn, err := f.dialUDP(ctx, ipp)
if err != nil {
return nil, err
}
// Specify the exact UDP family to work around https://github.com/golang/go/issues/52264
udpFam := "udp4"
if ipp.Addr().Is6() {
udpFam = "udp6"
}
conn, err := ln.ListenPacket(ctx, udpFam, ":0")
if err != nil {
f.logf("ListenPacket failed: %v", err)
return nil, err
}
defer conn.Close()
fq.closeOnCtxDone.Add(conn)
@@ -690,44 +868,72 @@ func (f *forwarder) sendUDP(ctx context.Context, fq *forwardQuery, rr resolverAn
// The 1 extra byte is to detect packet truncation.
out := make([]byte, maxResponseBytes+1)
n, _, err := conn.ReadFromUDPAddrPort(out)
if err != nil {
if err := ctx.Err(); err != nil {
return nil, err
// The conn is unconnected (see dialUDP), so datagrams can arrive from
// any address, not just the resolver we queried. A reply must come
// from the resolver's address and carry the transaction ID we sent.
// Datagrams that are neither are dropped rather than acted on, so
// neither a spoofed reply nor a single stray datagram can decide the
// query. The loop ends when the conn is closed, which the query's
// context cancellation does via fq.closeOnCtxDone.
var n int
for {
var src netip.AddrPort
var err error
n, src, err = conn.ReadFromUDPAddrPort(out)
if err != nil {
if err := ctx.Err(); err != nil {
return nil, err
}
if !neterror.PacketWasTruncated(err) {
metricDNSFwdUDPErrorRead.Add(1)
return nil, err
}
// Windows reports a datagram larger than out as a
// truncation error, returning the bytes that fit in
// out but no source address. Fall through and let the
// txid check decide, since the source can't be checked.
} else if src != ipp {
// Not from the resolver we asked, so not a reply to
// this query.
metricDNSFwdUDPDropSrc.Add(1)
continue
}
if neterror.PacketWasTruncated(err) {
err = nil
} else {
metricDNSFwdUDPErrorRead.Add(1)
return nil, err
if n < headerBytes {
f.logf("recv: packet too small (%d bytes)", n)
continue
}
if getTxID(out[:n]) != fq.txid {
metricDNSFwdUDPErrorTxID.Add(1)
continue
}
break
}
truncated := n > maxResponseBytes
if truncated {
n = maxResponseBytes
}
if n < headerBytes {
f.logf("recv: packet too small (%d bytes)", n)
}
out = out[:n]
txid := getTxID(out)
if txid != fq.txid {
metricDNSFwdUDPErrorTxID.Add(1)
return nil, errTxIDMismatch
}
tcFlagAlreadySet := truncatedFlagSet(out)
rcode := getRCode(out)
// don't forward transient errors back to the client when the server fails
if rcode == dns.RCodeServerFailure {
f.logf("recv: response code indicating server failure: %d", rcode)
switch rcode {
case dns.RCodeServerFailure:
f.logf("sendUDP: response code indicating server failure: %d", rcode)
metricDNSFwdUDPErrorServer.Add(1)
return nil, errServerFailure
return nil, rcodeResponseError{dns.RCodeServerFailure, out}
case dns.RCodeRefused:
// treat REFUSED as a soft error so other resolvers in the race can respond
f.logf("sendUDP: response code indicating refusal: %d", rcode)
metricDNSFwdUDPErrorRefused.Add(1)
return nil, rcodeResponseError{dns.RCodeRefused, out}
}
if truncated {
// Set the truncated bit if it wasn't already.
flags := binary.BigEndian.Uint16(out[2:4])
flags |= dnsFlagTruncated
binary.BigEndian.PutUint16(out[2:4], flags)
// Set the truncated bit if buffer was truncated during read and the flag isn't already set
if truncated && !tcFlagAlreadySet {
setTCFlag(out)
// TODO(#2067): Remove any incomplete records? RFC 1035 section 6.2
// states that truncation should head drop so that the authority
@@ -736,6 +942,8 @@ func (f *forwarder) sendUDP(ctx context.Context, fq *forwardQuery, rr resolverAn
// best we can do.
}
out = checkResponseSizeAndSetTC(out, fq.packet, fq.family, f.logf)
if truncatedFlagSet(out) {
metricDNSFwdTruncated.Add(1)
}
@@ -745,6 +953,58 @@ func (f *forwarder) sendUDP(ctx context.Context, fq *forwardQuery, rr resolverAn
return out, nil
}
// dialUDP returns a UDP conn to ipp, over netstack if that's the only way to
// reach it. Same dispatch as [tsdial.Dialer.dialOneUser].
func (f *forwarder) dialUDP(ctx context.Context, ipp netip.AddrPort) (nettype.PacketConn, error) {
if f.dialer.UseNetstackForIP != nil && f.dialer.UseNetstackForIP(ipp.Addr()) {
if f.dialer.NetstackDialUDP == nil {
return nil, errors.New("dialer not initialized correctly: no NetstackDialUDP")
}
conn, err := f.dialer.NetstackDialUDP(ctx, ipp)
if err != nil {
return nil, err
}
return &netstackPacketConn{Conn: conn, peer: ipp}, nil
}
ln, err := f.packetListener(ipp.Addr())
if err != nil {
return nil, err
}
// Name the family explicitly: netns looks for a "6" in this string to
// choose between IP_BOUND_IF and IPV6_BOUND_IF on macOS, and "udp" would
// give a v6 socket bound with the v4 option.
udpFam := "udp4"
if ipp.Addr().Is6() {
udpFam = "udp6"
}
conn, err := ln.ListenPacket(ctx, udpFam, ":0")
if err != nil {
f.logf("ListenPacket failed: %v", err)
return nil, err
}
return conn, nil
}
// netstackPacketConn presents a conn already connected to peer as a
// [nettype.PacketConn].
type netstackPacketConn struct {
net.Conn
peer netip.AddrPort
}
func (c *netstackPacketConn) WriteToUDPAddrPort(b []byte, _ netip.AddrPort) (int, error) {
return c.Write(b)
}
// ReadFromUDPAddrPort returns how much of the datagram fit in b; gVisor drops
// the rest without erroring, as a kernel socket does.
func (c *netstackPacketConn) ReadFromUDPAddrPort(b []byte) (int, netip.AddrPort, error) {
n, err := c.Read(b)
return n, c.peer, err
}
var optDNSForwardUseRoutes = envknob.RegisterOptBool("TS_DEBUG_DNS_FORWARD_USE_ROUTES")
// ShouldUseRoutes reports whether the DNS resolver should consider routes when dialing
@@ -765,7 +1025,7 @@ func ShouldUseRoutes(knobs *controlknobs.Knobs) bool {
switch runtime.GOOS {
case "android", "ios":
// On mobile platforms with lower memory limits (e.g., 50MB on iOS),
// this behavior is still gated by the "user-dial-routes" nodeAttr.
// this behavior is still gated by the "user-dial-routes" nodecap.
return knobs != nil && knobs.UserDialUseRoutes.Load()
default:
// On all other platforms, it is the default behavior,
@@ -856,10 +1116,16 @@ func (f *forwarder) sendTCP(ctx context.Context, fq *forwardQuery, rr resolverAn
rcode := getRCode(out)
// don't forward transient errors back to the client when the server fails
if rcode == dns.RCodeServerFailure {
switch rcode {
case dns.RCodeServerFailure:
f.logf("sendTCP: response code indicating server failure: %d", rcode)
metricDNSFwdTCPErrorServer.Add(1)
return nil, errServerFailure
return nil, rcodeResponseError{dns.RCodeServerFailure, out}
case dns.RCodeRefused:
// treat REFUSED as a soft error so other resolvers in the race can respond
f.logf("sendTCP: response code indicating refusal: %d", rcode)
metricDNSFwdTCPErrorRefused.Add(1)
return nil, rcodeResponseError{dns.RCodeRefused, out}
}
// TODO(andrew): do we need to do this?
@@ -868,15 +1134,66 @@ func (f *forwarder) sendTCP(ctx context.Context, fq *forwardQuery, rr resolverAn
return out, nil
}
// applySchemes resolves any custom-scheme entries in rrs using the provided
// scheme handlers, returning the resulting slice. Entries whose handler returns
// an error or empty string are dropped. Entries with no registered scheme pass
// through unchanged. If schemes is nil, rrs is returned as-is.
func applySchemes(logf logger.Logf, rrs []resolverAndDelay, schemes views.Map[string, CustomSchemeHandler]) []resolverAndDelay {
if schemes.IsNil() {
return rrs
}
var result []resolverAndDelay
for i, rr := range rrs {
scheme, _, hasColon := strings.Cut(rr.name.Addr, ":")
handler, isCustom := schemes.GetOk(scheme)
if !hasColon || !isCustom {
if result != nil {
result = append(result, rr)
}
continue
}
// Avoid making a results slice in the common case where there
// are no custom scheme resolvers.
if result == nil {
result = make([]resolverAndDelay, i, len(rrs))
copy(result, rrs)
}
newAddr, err := handler(rr.name.Addr)
if err != nil {
logf("error from custom scheme handler, skipping resolver : %v", err)
}
if err != nil || newAddr == "" {
continue
}
newResolver := *rr.name
newResolver.Addr = newAddr
result = append(result, resolverAndDelay{name: &newResolver, startDelay: rr.startDelay})
}
// If we didn't have any custom schemes, return the original rrs.
if result == nil {
return rrs
}
return result
}
// resolvers returns the resolvers to use for domain.
func (f *forwarder) resolvers(domain dnsname.FQDN) []resolverAndDelay {
f.mu.Lock()
routes := f.routes
cloudHostFallback := f.cloudHostFallback
schemes := f.schemeCacheLocked()
f.mu.Unlock()
for _, route := range routes {
if route.Suffix == "." || route.Suffix.Contains(domain) {
return route.Resolvers
if route.Suffix != "." && !route.Suffix.Contains(domain) {
continue
}
resolved := applySchemes(f.logf, route.Resolvers, schemes)
// If scheme resolution filtered out all resolvers from a non-empty
// route, fall through to the next matching route. If the resolvers
// were configured to be empty allow resolved to be empty.
if len(resolved) > 0 || len(route.Resolvers) == 0 {
return resolved
}
}
return cloudHostFallback // or nil if no fallback
@@ -893,6 +1210,39 @@ func (f *forwarder) GetUpstreamResolvers(name dnsname.FQDN) []*dnstype.Resolver
return upstreamResolvers
}
// RegisterCustomScheme adds a [CustomSchemeHandler] that is called to provide
// an updated address when a [dnstype.Resolver.Addr] uses that scheme.
func (f *forwarder) RegisterCustomScheme(scheme string, h CustomSchemeHandler) error {
f.mu.Lock()
defer f.mu.Unlock()
if _, ok := f.schemes[scheme]; ok {
return fmt.Errorf("scheme %q already registered", scheme)
}
f.invalidateSchemeCacheLocked()
mak.Set(&f.schemes, scheme, h)
return nil
}
// invalidateSchemeCacheLocked clears f.schemeCache so that it will be rebuilt
// on the next call to f.schemeCacheLocked().
func (f *forwarder) invalidateSchemeCacheLocked() {
f.schemeCache = views.Map[string, CustomSchemeHandler]{}
}
// schemeCacheLocked returns an immutable copy of f.schemes that can be used
// after mu is unlocked.
func (f *forwarder) schemeCacheLocked() views.Map[string, CustomSchemeHandler] {
if !f.schemeCache.IsNil() {
return f.schemeCache
}
if f.schemes == nil {
return f.schemeCache // returns a nil view
}
// Regenerate the cache
f.schemeCache = views.MapOf(maps.Clone(f.schemes))
return f.schemeCache
}
// forwardQuery is information and state about a forwarded DNS query that's
// being sent to 1 or more upstreams.
//
@@ -966,8 +1316,11 @@ func (f *forwarder) forwardWithDestChan(ctx context.Context, query packet, respo
if len(resolvers) == 0 {
resolvers = f.resolvers(domain)
if len(resolvers) == 0 {
// No upstream resolver for this name isn't a forwarder failure:
// it's split DNS / a name we weren't asked to handle. Count it
// rather than raising dnsForwarderFailing, which is reserved for
// resolvers we found but couldn't reach. See tailscale/tailscale#19931.
metricDNSFwdErrorNoUpstream.Add(1)
f.health.SetUnhealthy(dnsForwarderFailing, health.Args{health.ArgDNSServers: ""})
f.logf("no upstream resolvers set, returning SERVFAIL")
res, err := servfailResponse(query)
@@ -1031,6 +1384,7 @@ func (f *forwarder) forwardWithDestChan(ctx context.Context, query packet, respo
var firstErr error
var numErr int
var sawNonRefused bool
for {
select {
case v := <-resc:
@@ -1050,30 +1404,56 @@ func (f *forwarder) forwardWithDestChan(ctx context.Context, query packet, respo
if firstErr == nil {
firstErr = err
}
if !errors.Is(err, errRefused) {
sawNonRefused = true
}
numErr++
if numErr == len(resolvers) {
if errors.Is(firstErr, errServerFailure) {
res, err := servfailResponse(query)
if err != nil {
f.logf("building servfail response: %v", err)
var res packet
if sawNonRefused {
// At least one server failed with SERVFAIL or a transport error
// (e.g. network failure, TxID mismatch, unsupported resolver type).
// All such errors map to SERVFAIL at the client level.
// Prefer returning the upstream SERVFAIL bytes from firstErr if
// available; otherwise synthesize a SERVFAIL response. Note the
// rcode guard: firstErr may be a REFUSED rcodeResponseError if it
// arrived before the SERVFAIL that set sawNonRefused.
if rcodeErr, ok := errors.AsType[rcodeResponseError](firstErr); ok && rcodeErr.rcode == dns.RCodeServerFailure {
res = packet{rcodeErr.res, query.family, query.addr}
} else {
r, err := servfailResponse(query)
if err != nil {
f.logf("building servfail response: %v", err)
return firstErr
}
res = r
}
} else {
// !sawNonRefused means every error was an rcodeResponseError with rcode REFUSED,
// so firstErr is guaranteed to wrap one.
rcodeErr, ok := errors.AsType[rcodeResponseError](firstErr)
if !ok {
f.logf("unexpected: all errors were REFUSED but firstErr is not rcodeResponseError: %v", firstErr)
return firstErr
}
select {
case <-ctx.Done():
metricDNSFwdErrorContext.Add(1)
metricDNSFwdErrorContextGotError.Add(1)
var resolverAddrs []string
for _, rr := range resolvers {
resolverAddrs = append(resolverAddrs, rr.name.Addr)
}
f.health.SetUnhealthy(dnsForwarderFailing, health.Args{health.ArgDNSServers: strings.Join(resolverAddrs, ",")})
case responseChan <- res:
if f.verboseFwd {
f.logf("forwarder response(%d, %v, %d) = %d, %v", fq.txid, typ, len(domain), len(res.bs), firstErr)
}
return nil
res = packet{rcodeErr.res, query.family, query.addr}
}
select {
case <-ctx.Done():
metricDNSFwdErrorContext.Add(1)
metricDNSFwdErrorContextGotError.Add(1)
var resolverAddrs []string
for _, rr := range resolvers {
resolverAddrs = append(resolverAddrs, rr.name.Addr)
}
if f.acceptDNS {
f.health.SetUnhealthy(dnsForwarderFailing, health.Args{health.ArgDNSServers: strings.Join(resolverAddrs, ",")})
}
case responseChan <- res:
if f.verboseFwd {
f.logf("forwarder response(%d, %v, %d) = %d, %v", fq.txid, typ, len(domain), len(res.bs), firstErr)
}
return nil
}
return firstErr
}
@@ -1086,13 +1466,14 @@ func (f *forwarder) forwardWithDestChan(ctx context.Context, query packet, respo
// If we haven't got an error or a successful response,
// include all resolvers in the error message so we can
// at least see what what servers we're trying to
// query.
// at least see what servers we're trying to query.
var resolverAddrs []string
for _, rr := range resolvers {
resolverAddrs = append(resolverAddrs, rr.name.Addr)
}
f.health.SetUnhealthy(dnsForwarderFailing, health.Args{health.ArgDNSServers: strings.Join(resolverAddrs, ",")})
if f.acceptDNS {
f.health.SetUnhealthy(dnsForwarderFailing, health.Args{health.ArgDNSServers: strings.Join(resolverAddrs, ",")})
}
return fmt.Errorf("waiting for response or error from %v: %w", resolverAddrs, ctx.Err())
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build ts_macext && (darwin || ios)
+253 -71
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package resolver implements a stub DNS resolver that can also serve
@@ -16,7 +16,7 @@ import (
"net/netip"
"os"
"runtime"
"sort"
"slices"
"strconv"
"strings"
"sync"
@@ -39,6 +39,7 @@ import (
"tailscale.com/util/clientmetric"
"tailscale.com/util/cloudenv"
"tailscale.com/util/dnsname"
"tailscale.com/util/set"
)
const dnsSymbolicFQDN = "magicdns.localhost-tailscale-daemon."
@@ -48,8 +49,26 @@ const dnsSymbolicFQDN = "magicdns.localhost-tailscale-daemon."
// truncation in a platform-agnostic way.
const maxResponseBytes = 4095
// defaultTTL is the TTL of all responses from Resolver.
const defaultTTL = 600 * time.Second
// defaultTTL is the TTL of positive responses from Resolver.
//
// It's short because the source of truth (the netmap-fed host maps)
// is local and in-memory, so re-queries are nearly free, while
// anything cached downstream (e.g. mDNSResponder on macOS) delays
// clients noticing node renames for the full TTL (tailscale/corp#45631).
const defaultTTL = 5 * time.Second
// negativeTTL is how long resolvers may cache the nonexistence of a
// name (or of records of the queried type) for domains we're
// authoritative for. It's advertised via the SOA record attached to
// the authority section of NXDOMAIN and no-data responses, per RFC
// 2308. Without it, some resolvers (notably mDNSResponder) seem to
// cache negative entries for a really long time, so a name queried
// shortly before a node rename doesn't start resolving for a while
// (tailscale/corp#45631).
const negativeTTL = 10 * time.Second
// timeNow is time.Now, except in tests.
var timeNow = time.Now
var (
errNotQuery = errors.New("not a DNS query")
@@ -69,6 +88,9 @@ type packet struct {
// Else forward the query to the most specific matching entry in Routes.
// Else return SERVFAIL.
type Config struct {
// True if [Prefs.CorpDNS] is true or --accept-dns=true was specified.
// This should only be used for error handling and health reporting.
AcceptDNS bool
// Routes is a map of DNS name suffix to the resolvers to use for
// queries within that suffix.
// Queries only match the most specific suffix.
@@ -79,6 +101,12 @@ type Config struct {
// LocalDomains is a list of DNS name suffixes that should not be
// routed to upstream resolvers.
LocalDomains []dnsname.FQDN
// SubdomainHosts is a set of FQDNs from Hosts that should also
// resolve subdomain queries to the same IPs. If a query like
// "sub.node.tailnet.ts.net" doesn't match Hosts directly, and
// "node.tailnet.ts.net" is in SubdomainHosts, the query resolves
// to the IPs for "node.tailnet.ts.net".
SubdomainHosts set.Set[dnsname.FQDN]
}
// WriteToBufioWriter write a debug version of c for logs to w, omitting
@@ -162,7 +190,7 @@ func WriteRoutes(w *bufio.Writer, routes map[dnsname.FQDN][]*dnstype.Resolver) {
}
kk = append(kk, k)
}
sort.Slice(kk, func(i, j int) bool { return kk[i] < kk[j] })
slices.Sort(kk)
w.WriteByte('{')
for i, k := range kk {
if i > 0 {
@@ -214,10 +242,61 @@ type Resolver struct {
closed chan struct{}
// mu guards the following fields from being updated while used.
mu syncs.Mutex
localDomains []dnsname.FQDN
hostToIP map[dnsname.FQDN][]netip.Addr
ipToHost map[netip.Addr]dnsname.FQDN
mu syncs.Mutex
localDomains []dnsname.FQDN
hostToIP map[dnsname.FQDN][]netip.Addr
ipToHost map[netip.Addr]dnsname.FQDN
subdomainHosts set.Set[dnsname.FQDN]
magicHosts MagicDNSHosts // or nil if none installed
}
// MagicDNSHosts is a live source of MagicDNS host records, installed
// via [Resolver.SetMagicDNSHosts].
//
// It replaces the per-node entries of [Config.Hosts]: instead of the
// caller pushing a full snapshot of every node's name and addresses
// into the resolver on every (possibly incremental) netmap change,
// the resolver pulls the answer for one name on demand from the
// caller's live indexes. [Config.Hosts] remains for control's
// DNS.ExtraRecords entries, which are few, and is consulted first.
//
// Implementations must be safe for concurrent use and cheap: the
// methods are called on the DNS query serving path. Name lookups are
// case-insensitive: the resolver passes lowercase names, but
// implementations must not rely on that.
//
// Short names ("foo") are only resolved if MagicDNS is enabled
// for the current tailnet.
type MagicDNSHosts interface {
// LookupHost returns the IPs to answer for the node with the
// given MagicDNS FQDN, and whether the name is known. It returns
// all answerable IPs regardless of record type; the resolver
// filters them by the query's type, and a known name with no IPs
// of the query's family is "name exists, no records", not
// NXDOMAIN.
LookupHost(dnsname.FQDN) (ips []netip.Addr, ok bool)
// LookupPTR returns the MagicDNS FQDN of the node that owns
// the given Tailscale IP, and whether the IP is known.
LookupPTR(netip.Addr) (_ dnsname.FQDN, ok bool)
// SubdomainHost reports whether fqdn names a node with the
// [tailcfg.NodeAttrDNSSubdomainResolve] attribute, whose
// subdomains all resolve to the node's own addresses.
SubdomainHost(dnsname.FQDN) bool
}
// SetMagicDNSHosts installs the live MagicDNS host source consulted
// by forward and reverse MagicDNS lookups that miss [Config.Hosts].
// It is expected to be called once, before the resolver serves
// queries.
func (r *Resolver) SetMagicDNSHosts(h MagicDNSHosts) {
if !buildfeatures.HasDNS {
return
}
r.mu.Lock()
defer r.mu.Unlock()
r.magicHosts = h
}
type ForwardLinkSelector interface {
@@ -255,6 +334,16 @@ func New(logf logger.Logf, linkSel ForwardLinkSelector, dialer *tsdial.Dialer, h
func (r *Resolver) TestOnlySetHook(hook func(Config)) { r.saveConfigForTests = hook }
// ProbeLocks acquires and releases the resolver's internal mutexes.
func (r *Resolver) ProbeLocks() {
r.mu.Lock()
r.mu.Unlock()
if r.forwarder != nil {
r.forwarder.probeLocks()
}
}
func (r *Resolver) SetConfig(cfg Config) error {
if !buildfeatures.HasDNS {
return nil
@@ -271,16 +360,29 @@ func (r *Resolver) SetConfig(cfg Config) error {
}
}
r.forwarder.setRoutes(cfg.Routes)
r.forwarder.setRoutes(cfg.Routes, cfg.AcceptDNS)
r.mu.Lock()
defer r.mu.Unlock()
r.localDomains = cfg.LocalDomains
r.hostToIP = cfg.Hosts
r.ipToHost = reverse
r.subdomainHosts = cfg.SubdomainHosts
return nil
}
// CustomSchemeHandler takes a URI (retrieved from [dnstype.Resolver.Addr]) and
// returns an updated URI to use for the current query. The result is only valid
// for right now and may change over time.
type CustomSchemeHandler func(addr string) (newAddr string, err error)
// RegisterCustomScheme adds a [CustomSchemaHandler] that is called to provide
// an updated address to the forwarder when a [dnstype.Resolver.Addr] uses that
// scheme.
func (r *Resolver) RegisterCustomScheme(scheme string, h CustomSchemeHandler) error {
return r.forwarder.RegisterCustomScheme(scheme, h)
}
// Close shuts down the resolver and ensures poll goroutines have exited.
// The Resolver cannot be used again after Close is called.
func (r *Resolver) Close() {
@@ -328,7 +430,12 @@ func (r *Resolver) Query(ctx context.Context, bs []byte, family string, from net
return (<-responses).bs, nil
}
return out, err
if err != nil {
return out, err
}
out = checkResponseSizeAndSetTC(out, bs, family, r.logf)
return out, nil
}
// GetUpstreamResolvers returns the resolvers that would be used to resolve
@@ -642,9 +749,26 @@ func (r *Resolver) resolveLocal(domain dnsname.FQDN, typ dns.Type) (netip.Addr,
r.mu.Lock()
hosts := r.hostToIP
localDomains := r.localDomains
subdomainHosts := r.subdomainHosts
magicHosts := r.magicHosts
r.mu.Unlock()
addrs, found := hosts[domain]
if !found && magicHosts != nil {
addrs, found = magicHosts.LookupHost(domain)
}
if !found {
for parent := domain.Parent(); parent != ""; parent = parent.Parent() {
if subdomainHosts.Contains(parent) {
addrs, found = hosts[parent]
break
}
if magicHosts != nil && magicHosts.SubdomainHost(parent) {
addrs, found = magicHosts.LookupHost(parent)
break
}
}
}
if !found {
for _, suffix := range localDomains {
if suffix.Contains(domain) {
@@ -715,23 +839,16 @@ func (r *Resolver) resolveLocal(domain dnsname.FQDN, typ dns.Type) (netip.Addr,
}
// resolveViaDomain synthesizes an IP address for quad-A DNS requests of the form
// `<IPv4-address-with-hypens-instead-of-dots>-via-<siteid>[.*]`. Two prior formats that
// didn't pan out (due to a Chrome issue and DNS search ndots issues) were
// `<IPv4-address>.via-<X>` and the older `via-<X>.<IPv4-address>`,
// where X is a decimal, or hex-encoded number with a '0x' prefix.
// `<IPv4-address-with-hypens-instead-of-dots>-via-<siteid>[.*]`.
// For example: "192-168-1-2-via-7" or "192-168-1-2-via-7.foo.ts.net."
//
// This exists as a convenient mapping into Tailscales 'Via Range'.
//
// It returns a zero netip.Addr and true to indicate a successful response with
// an empty answers section if the specified domain is a valid Tailscale 4via6
// domain, but the request type is neither quad-A nor ALL.
//
// TODO(maisem/bradfitz/tom): `<IPv4-address>.via-<X>` was introduced
// (2022-06-02) to work around an issue in Chrome where it would treat
// "http://via-1.1.2.3.4" as a search string instead of a URL. We should rip out
// the old format in early 2023.
func (r *Resolver) resolveViaDomain(domain dnsname.FQDN, typ dns.Type) (netip.Addr, bool) {
fqdn := string(domain.WithoutTrailingDot())
func (r *Resolver) resolveViaDomain(dnsName dnsname.FQDN, typ dns.Type) (netip.Addr, bool) {
fqdn := string(dnsName.WithoutTrailingDot())
switch typ {
case dns.TypeA, dns.TypeAAAA, dns.TypeALL:
// For Type A requests, we should return a successful response
@@ -745,45 +862,23 @@ func (r *Resolver) resolveViaDomain(domain dnsname.FQDN, typ dns.Type) (netip.Ad
default:
return netip.Addr{}, false
}
if len(fqdn) < len("via-X.0.0.0.0") {
if len(fqdn) < len("0-0-0-0-via-0") {
return netip.Addr{}, false // too short to be valid
}
var siteID string
var ip4Str string
switch {
case strings.Contains(fqdn, "-via-"):
// Format number 3: "192-168-1-2-via-7" or "192-168-1-2-via-7.foo.ts.net."
// Third time's a charm. The earlier two formats follow after this block.
firstLabel, domain, _ := strings.Cut(fqdn, ".") // "192-168-1-2-via-7"
if !(domain == "" || dnsname.HasSuffix(domain, "ts.net") || dnsname.HasSuffix(domain, "tailscale.net")) {
return netip.Addr{}, false
}
v4hyphens, suffix, ok := strings.Cut(firstLabel, "-via-")
if !ok {
return netip.Addr{}, false
}
siteID = suffix
ip4Str = strings.ReplaceAll(v4hyphens, "-", ".")
case strings.HasPrefix(fqdn, "via-"):
firstDot := strings.Index(fqdn, ".")
if firstDot < 0 {
return netip.Addr{}, false // missing dot delimiters
}
siteID = fqdn[len("via-"):firstDot]
ip4Str = fqdn[firstDot+1:]
default:
lastDot := strings.LastIndex(fqdn, ".")
if lastDot < 0 {
return netip.Addr{}, false // missing dot delimiters
}
suffix := fqdn[lastDot+1:]
if !strings.HasPrefix(suffix, "via-") {
return netip.Addr{}, false
}
siteID = suffix[len("via-"):]
ip4Str = fqdn[:lastDot]
if !strings.Contains(fqdn, "-via-") {
return netip.Addr{}, false // not a 4via6 domain
}
firstLabel, domain, _ := strings.Cut(fqdn, ".") // "192-168-1-2-via-7"
if !(domain == "" || dnsname.HasSuffix(domain, "ts.net") || dnsname.HasSuffix(domain, "tailscale.net")) {
return netip.Addr{}, false
}
v4hyphens, suffix, ok := strings.Cut(firstLabel, "-via-")
if !ok {
return netip.Addr{}, false
}
siteID := suffix
ip4Str := strings.ReplaceAll(v4hyphens, "-", ".")
ip4, err := netip.ParseAddr(ip4Str)
if err != nil {
@@ -846,6 +941,9 @@ func (r *Resolver) fqdnForIPLocked(ip netip.Addr, name dnsname.FQDN) (dnsname.FQ
}
ret, ok := r.ipToHost[ip]
if !ok && r.magicHosts != nil {
ret, ok = r.magicHosts.LookupPTR(ip)
}
if !ok {
for _, suffix := range r.localDomains {
if suffix.Contains(name) {
@@ -883,6 +981,14 @@ type response struct {
// NSs are the responses to an NS query.
NSs []*net.NS
// SOAZone, if non-empty, is the zone we're authoritative for
// that contains Question's name. marshalResponse attaches its
// SOA record to the authority section so that resolvers bound
// their negative caching to negativeTTL, per RFC 2308. It must
// only be set on negative responses: NXDOMAIN, or success with
// no records of the queried type.
SOAZone dnsname.FQDN
}
var dnsParserPool = &sync.Pool{
@@ -1064,6 +1170,35 @@ func marshalSRV(queryName dns.Name, srvs []*net.SRV, builder *dns.Builder) error
return nil
}
// marshalSOA serializes zone's SOA record into the authority section
// of an active builder, which must have had StartAuthorities called.
// Its only purpose is communicating negativeTTL to caching resolvers
// (RFC 2308), so all fields other than the TTLs are placeholders.
func marshalSOA(zone dnsname.FQDN, builder *dns.Builder) error {
name, err := dns.NewName(zone.WithTrailingDot())
if err != nil {
return err
}
return builder.SOAResource(dns.ResourceHeader{
Name: name,
Type: dns.TypeSOA,
Class: dns.ClassINET,
TTL: uint32(negativeTTL / time.Second),
}, dns.SOAResource{
NS: name,
MBox: name,
// A serial should only change when the zone data does,
// but nothing consumes ours (no secondaries, no zone
// transfers), and the current time is at least monotonic
// and cheap. Unix seconds fit in the uint32 until 2106.
Serial: uint32(timeNow().Unix()),
Refresh: uint32(negativeTTL / time.Second),
Retry: uint32(negativeTTL / time.Second),
Expire: uint32(negativeTTL / time.Second),
MinTTL: uint32(negativeTTL / time.Second),
})
}
// marshalResponse serializes the DNS response into a new buffer.
func marshalResponse(resp *response) ([]byte, error) {
resp.Header.Response = true
@@ -1098,6 +1233,14 @@ func marshalResponse(resp *response) ([]byte, error) {
// Only successful responses contain answers.
if !isSuccess {
if resp.SOAZone != "" {
if err := builder.StartAuthorities(); err != nil {
return nil, err
}
if err := marshalSOA(resp.SOAZone, &builder); err != nil {
return nil, err
}
}
return builder.Finish()
}
@@ -1131,6 +1274,15 @@ func marshalResponse(resp *response) ([]byte, error) {
return nil, err
}
if resp.SOAZone != "" {
if err := builder.StartAuthorities(); err != nil {
return nil, err
}
if err := marshalSOA(resp.SOAZone, &builder); err != nil {
return nil, err
}
}
return builder.Finish()
}
@@ -1248,6 +1400,19 @@ func rdnsNameToIPv6(name dnsname.FQDN) (ip netip.Addr, ok bool) {
// respondReverse returns a DNS response to a PTR query.
// It is assumed that resp.Question is populated by respond before this is called.
// authoritativeZoneFor returns the zone from the configured local
// domains that contains name, or "" if none does.
func (r *Resolver) authoritativeZoneFor(name dnsname.FQDN) dnsname.FQDN {
r.mu.Lock()
defer r.mu.Unlock()
for _, suffix := range r.localDomains {
if suffix.Contains(name) {
return suffix
}
}
return ""
}
func (r *Resolver) respondReverse(query []byte, name dnsname.FQDN, resp *response) ([]byte, error) {
if hasRDNSBonjourPrefix(name) {
metricDNSReverseMissBonjour.Add(1)
@@ -1259,6 +1424,9 @@ func (r *Resolver) respondReverse(query []byte, name dnsname.FQDN, resp *respons
metricDNSReverseMissOther.Add(1)
return nil, errNotOurName
}
if resp.Header.RCode == dns.RCodeNameError {
resp.SOAZone = r.authoritativeZoneFor(name)
}
metricDNSMagicDNSSuccessReverse.Add(1)
return marshalResponse(resp)
@@ -1315,6 +1483,17 @@ func (r *Resolver) respond(query []byte) ([]byte, error) {
resp := parser.response()
resp.Header.RCode = rcode
resp.IP = ip
switch {
case rcode == dns.RCodeNameError:
resp.SOAZone = r.authoritativeZoneFor(name)
case rcode == dns.RCodeSuccess && !ip.IsValid():
switch parser.Question.Type {
case dns.TypeA, dns.TypeAAAA, dns.TypeALL:
// The name exists but has no records of the queried
// type (e.g. an AAAA query for an IPv4-only node).
resp.SOAZone = r.authoritativeZoneFor(name)
}
}
metricDNSMagicDNSSuccessName.Add(1)
return marshalResponse(resp)
}
@@ -1376,21 +1555,24 @@ var (
metricDNSFwdErrorType = clientmetric.NewCounter("dns_query_fwd_error_type")
metricDNSFwdTruncated = clientmetric.NewCounter("dns_query_fwd_truncated")
metricDNSFwdUDP = clientmetric.NewCounter("dns_query_fwd_udp") // on entry
metricDNSFwdUDPWrote = clientmetric.NewCounter("dns_query_fwd_udp_wrote") // sent UDP packet
metricDNSFwdUDPErrorWrite = clientmetric.NewCounter("dns_query_fwd_udp_error_write")
metricDNSFwdUDPErrorServer = clientmetric.NewCounter("dns_query_fwd_udp_error_server")
metricDNSFwdUDPErrorTxID = clientmetric.NewCounter("dns_query_fwd_udp_error_txid")
metricDNSFwdUDPErrorRead = clientmetric.NewCounter("dns_query_fwd_udp_error_read")
metricDNSFwdUDPSuccess = clientmetric.NewCounter("dns_query_fwd_udp_success")
metricDNSFwdUDP = clientmetric.NewCounter("dns_query_fwd_udp") // on entry
metricDNSFwdUDPWrote = clientmetric.NewCounter("dns_query_fwd_udp_wrote") // sent UDP packet
metricDNSFwdUDPDropSrc = clientmetric.NewCounter("dns_query_fwd_udp_drop_src")
metricDNSFwdUDPErrorWrite = clientmetric.NewCounter("dns_query_fwd_udp_error_write")
metricDNSFwdUDPErrorServer = clientmetric.NewCounter("dns_query_fwd_udp_error_server")
metricDNSFwdUDPErrorRefused = clientmetric.NewCounter("dns_query_fwd_udp_error_refused")
metricDNSFwdUDPErrorTxID = clientmetric.NewCounter("dns_query_fwd_udp_error_txid")
metricDNSFwdUDPErrorRead = clientmetric.NewCounter("dns_query_fwd_udp_error_read")
metricDNSFwdUDPSuccess = clientmetric.NewCounter("dns_query_fwd_udp_success")
metricDNSFwdTCP = clientmetric.NewCounter("dns_query_fwd_tcp") // on entry
metricDNSFwdTCPWrote = clientmetric.NewCounter("dns_query_fwd_tcp_wrote") // sent TCP packet
metricDNSFwdTCPErrorWrite = clientmetric.NewCounter("dns_query_fwd_tcp_error_write")
metricDNSFwdTCPErrorServer = clientmetric.NewCounter("dns_query_fwd_tcp_error_server")
metricDNSFwdTCPErrorTxID = clientmetric.NewCounter("dns_query_fwd_tcp_error_txid")
metricDNSFwdTCPErrorRead = clientmetric.NewCounter("dns_query_fwd_tcp_error_read")
metricDNSFwdTCPSuccess = clientmetric.NewCounter("dns_query_fwd_tcp_success")
metricDNSFwdTCP = clientmetric.NewCounter("dns_query_fwd_tcp") // on entry
metricDNSFwdTCPWrote = clientmetric.NewCounter("dns_query_fwd_tcp_wrote") // sent TCP packet
metricDNSFwdTCPErrorWrite = clientmetric.NewCounter("dns_query_fwd_tcp_error_write")
metricDNSFwdTCPErrorServer = clientmetric.NewCounter("dns_query_fwd_tcp_error_server")
metricDNSFwdTCPErrorRefused = clientmetric.NewCounter("dns_query_fwd_tcp_error_refused")
metricDNSFwdTCPErrorTxID = clientmetric.NewCounter("dns_query_fwd_tcp_error_txid")
metricDNSFwdTCPErrorRead = clientmetric.NewCounter("dns_query_fwd_tcp_error_read")
metricDNSFwdTCPSuccess = clientmetric.NewCounter("dns_query_fwd_tcp_success")
metricDNSFwdDoH = clientmetric.NewCounter("dns_query_fwd_doh")
metricDNSFwdDoHErrorStatus = clientmetric.NewCounter("dns_query_fwd_doh_error_status")
+2 -5
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package dns
@@ -28,10 +28,7 @@ func maybeUnUTF16(bs []byte) []byte {
// Can't be complete UTF-16.
return bs
}
checkLen := 20
if len(bs) < checkLen {
checkLen = len(bs)
}
checkLen := min(len(bs), 20)
zeroOff := bytes.IndexByte(bs[:checkLen], 0)
if zeroOff == -1 {
return bs
+4 -3
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package dns
@@ -148,6 +148,8 @@ type wslFS struct {
distro string
}
func (fs wslFS) ActualPath(name string) string { return name }
func (fs wslFS) Stat(name string) (isRegular bool, err error) {
err = wslRun(fs.cmd("test", "-f", name))
if ee, _ := err.(*exec.ExitError); ee != nil {
@@ -172,8 +174,7 @@ func (fs wslFS) Truncate(name string) error { return fs.WriteFile(name, nil, 064
func (fs wslFS) ReadFile(name string) ([]byte, error) {
b, err := wslCombinedOutput(fs.cmd("cat", "--", name))
var ee *exec.ExitError
if errors.As(err, &ee) && ee.ExitCode() == 1 {
if ee, ok := errors.AsType[*exec.ExitError](err); ok && ee.ExitCode() == 1 {
return nil, os.ErrNotExist
}
return b, err
+150 -118
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package dnscache contains a minimal DNS cache that makes a bunch of
@@ -8,26 +8,77 @@ package dnscache
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"log"
"net"
"net/netip"
"runtime"
"strconv"
"sync"
"sync/atomic"
"time"
"tailscale.com/envknob"
"tailscale.com/feature"
"tailscale.com/feature/buildfeatures"
"tailscale.com/net/bakedroots"
"tailscale.com/net/netx"
"tailscale.com/syncs"
"tailscale.com/types/logger"
"tailscale.com/util/clientmetric"
"tailscale.com/util/cloudenv"
"tailscale.com/util/singleflight"
"tailscale.com/util/slicesx"
"tailscale.com/util/testenv"
)
// HookSetCacheDir optionally points to the dnsresolvecache feature's
// function to configure the directory in which resolved IPs are
// persisted to disk. It is called by tailscaled at startup, if the
// feature is linked in.
var HookSetCacheDir feature.Hook[func(dir string, logf logger.Logf)]
// HookPersistResolution optionally points to the dnsresolvecache
// feature's function to record a successful DNS resolution of host
// to disk. The resolver argument is one of the "forward", "cloud",
// or "fallback" resolver source names. The feature defers the actual
// disk write until [HookHostVerified] confirms one of the
// resolution's IPs.
var HookPersistResolution feature.Hook[func(host, resolver string, ips []netip.Addr)]
// HookLookupDiskCache optionally points to the dnsresolvecache
// feature's function to load previously persisted IPs for host from
// disk. It is consulted only after regular DNS resolution has
// failed, before falling back to the DERP-based bootstrap DNS.
var HookLookupDiskCache feature.Hook[func(host string) ([]netip.Addr, bool)]
// HookHostVerified optionally points to the dnsresolvecache
// feature's function to record that a TLS connection to host, at the
// given remote IP, presented a certificate chain that is valid for
// host. That is checked independently of the connection's own TLS
// config, which may deliberately tolerate interception (as the
// control plane Noise connection does). The feature uses this to
// flush a pending resolution of host to disk only once one of its
// IPs has been cryptographically verified to be host.
var HookHostVerified feature.Hook[func(host string, ip netip.Addr)]
// Resolver source names, as passed to [HookPersistResolution] and
// used for deciding fallback behavior in Resolver.lookupIP.
const (
srcForward = "forward" // Resolver.Forward (or the test hook)
srcCloud = "cloud" // cloud host resolver (e.g. GCP metadata resolver)
srcDisk = "disk" // dnsresolvecache disk cache of an earlier resolution
srcFallback = "fallback" // Resolver.LookupIPFallback (DERP-based bootstrap DNS)
)
var (
metricDiskFallbackHit = clientmetric.NewCounter("dnscache_disk_fallback_hit")
metricDiskFallbackMiss = clientmetric.NewCounter("dnscache_disk_fallback_miss")
metricDERPFallbackOK = clientmetric.NewCounter("dnscache_derp_fallback_ok")
metricDERPFallbackDialOK = clientmetric.NewCounter("dnscache_derp_fallback_dial_ok")
)
var zaddr netip.Addr
var single = &Resolver{
@@ -295,15 +346,28 @@ func (r *Resolver) lookupIP(ctx context.Context, host string) (ip, ip6 netip.Add
defer lookupCancel()
var ips []netip.Addr
src := srcForward
if r.LookupIPForTest != nil && testenv.InTest() {
ips, err = r.LookupIPForTest(ctx, host)
} else {
ips, err = r.fwd().LookupNetIP(lookupCtx, "ip", host)
if err != nil || len(ips) == 0 {
if resolver, ok := r.cloudHostResolver(); ok {
r.dlogf("resolving %q via cloud resolver", host)
src = srcCloud
ips, err = resolver.LookupNetIP(lookupCtx, "ip", host)
}
}
}
if err != nil || len(ips) == 0 {
if resolver, ok := r.cloudHostResolver(); ok {
r.dlogf("resolving %q via cloud resolver", host)
ips, err = resolver.LookupNetIP(lookupCtx, "ip", host)
if buildfeatures.HasDNSResolveCache && (err != nil || len(ips) == 0) {
if f, ok := HookLookupDiskCache.GetOk(); ok {
if cached, ok := f(host); ok {
r.dlogf("resolving %q from disk cache after error", host)
metricDiskFallbackHit.Add(1)
ips, err, src = cached, nil, srcDisk
} else {
metricDiskFallbackMiss.Add(1)
}
}
}
if (err != nil || len(ips) == 0) && r.LookupIPFallback != nil {
@@ -314,7 +378,11 @@ func (r *Resolver) lookupIP(ctx context.Context, host string) (ip, ip6 netip.Add
} else {
r.dlogf("resolving %q using fallback resolver due to no returned IPs", host)
}
src = srcFallback
ips, err = r.LookupIPFallback(lookupCtx, host)
if err == nil && len(ips) > 0 {
metricDERPFallbackOK.Add(1)
}
}
if err != nil {
return netip.Addr{}, netip.Addr{}, nil, err
@@ -344,11 +412,11 @@ func (r *Resolver) lookupIP(ctx context.Context, host string) (ip, ip6 netip.Add
}
}
}
r.addIPCache(host, ip, ip6, ips, r.ttl())
r.addIPCache(host, src, ip, ip6, ips, r.ttl())
return ip, ip6, ips, nil
}
func (r *Resolver) addIPCache(host string, ip, ip6 netip.Addr, allIPs []netip.Addr, d time.Duration) {
func (r *Resolver) addIPCache(host, src string, ip, ip6 netip.Addr, allIPs []netip.Addr, d time.Duration) {
if ip.IsPrivate() {
// Don't cache obviously wrong entries from captive portals.
// TODO: use DoH or DoT for the forwarding resolver?
@@ -356,6 +424,14 @@ func (r *Resolver) addIPCache(host string, ip, ip6 netip.Addr, allIPs []netip.Ad
return
}
// Skip persisting disk-sourced results to avoid rewriting the
// disk cache files with their own contents.
if buildfeatures.HasDNSResolveCache && src != srcDisk {
if f, ok := HookPersistResolution.GetOk(); ok {
f(host, src, allIPs)
}
}
r.dlogf("%q resolved to IP %v; caching", host, ip)
r.mu.Lock()
@@ -416,30 +492,28 @@ func (d *dialer) DialContext(ctx context.Context, network, address string) (retC
return
}
if c, err := dc.raceDial(ctx, ips); err == nil {
metricDERPFallbackDialOK.Add(1)
retConn = c
ret = nil
return
}
}()
ip, ip6, allIPs, err := d.dnsCache.LookupIP(ctx, host)
ip, _, allIPs, err := d.dnsCache.LookupIP(ctx, host)
if err != nil {
return nil, fmt.Errorf("failed to resolve %q: %w", host, err)
}
i4s := v4addrs(allIPs)
if len(i4s) < 2 {
// If we only have one candidate, just dial that, no matter what the
// address family is.
if len(allIPs) == 1 {
d.dnsCache.dlogf("dialing %s, %s for %s", network, ip, address)
c, err := dc.dialOne(ctx, ip.Unmap())
if err == nil || ctx.Err() != nil || !ip6.IsValid() {
return c, err
}
// Fall back to trying IPv6.
return dc.dialOne(ctx, ip6)
return dc.dialOne(ctx, ip.Unmap())
}
// Multiple IPv4 candidates, and 0+ IPv6.
ipsToTry := append(i4s, v6addrs(allIPs)...)
return dc.raceDial(ctx, ipsToTry)
// If we have multiple candidates, across address families, use happy
// eyeballs to find a connection.
return dc.raceDial(ctx, allIPs)
}
func (d *dialer) shouldTryBootstrap(ctx context.Context, err error, dc *dialCall) bool {
@@ -555,16 +629,6 @@ const fallbackDelay = 300 * time.Millisecond
// raceDial tries to dial port on each ip in ips, starting a new race
// dial every fallbackDelay apart, returning whichever completes first.
func (dc *dialCall) raceDial(ctx context.Context, ips []netip.Addr) (net.Conn, error) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
type res struct {
c net.Conn
err error
}
resc := make(chan res) // must be unbuffered
failBoost := make(chan struct{}) // best effort send on dial failure
// Remove IPs that we tried & failed to dial previously
// (such as when we're being called after a dnsfallback lookup and get
// the same results)
@@ -572,96 +636,20 @@ func (dc *dialCall) raceDial(ctx context.Context, ips []netip.Addr) (net.Conn, e
if len(ips) == 0 {
return nil, errors.New("no IPs")
}
// Partition candidate list and then merge such that an IPv6 address is
// in the first spot if present, and then addresses are interleaved.
// This ensures that we're trying an IPv6 address first, then
// alternating between v4 and v6 in case one of the two networks is
// broken.
var iv4, iv6 []netip.Addr
for _, ip := range ips {
if ip.Is6() {
iv6 = append(iv6, ip)
} else {
iv4 = append(iv4, ip)
}
port, err := strconv.ParseUint(dc.port, 10, 16)
if err != nil {
return nil, fmt.Errorf("invalid port %q: %w", dc.port, err)
}
ips = slicesx.Interleave(iv6, iv4)
go func() {
for i, ip := range ips {
if i != 0 {
timer := time.NewTimer(fallbackDelay)
select {
case <-timer.C:
case <-failBoost:
timer.Stop()
case <-ctx.Done():
timer.Stop()
return
}
}
go func(ip netip.Addr) {
c, err := dc.dialOne(ctx, ip)
if err != nil {
// Best effort wake-up a pending dial.
// e.g. IPv4 dials failing quickly on an IPv6-only system.
// In that case we don't want to wait 300ms per IPv4 before
// we get to the IPv6 addresses.
select {
case failBoost <- struct{}{}:
default:
}
}
select {
case resc <- res{c, err}:
case <-ctx.Done():
if c != nil {
c.Close()
}
}
}(ip)
}
}()
var firstErr error
var fails int
for {
select {
case r := <-resc:
if r.c != nil {
return r.c, nil
}
fails++
if firstErr == nil {
firstErr = r.err
}
if fails == len(ips) {
return nil, firstErr
}
case <-ctx.Done():
return nil, ctx.Err()
}
addrs := make([]netip.AddrPort, len(ips))
for i, ip := range ips {
addrs[i] = netip.AddrPortFrom(ip, uint16(port))
}
}
func v4addrs(aa []netip.Addr) (ret []netip.Addr) {
for _, a := range aa {
a = a.Unmap()
if a.Is4() {
ret = append(ret, a)
}
}
return ret
}
func v6addrs(aa []netip.Addr) (ret []netip.Addr) {
for _, a := range aa {
if a.Is6() && !a.Is4In6() {
ret = append(ret, a)
}
}
return ret
return netx.RaceDial(ctx, addrs, func(ctx context.Context, network, address string) (net.Conn, error) {
c, err := dc.d.fwd(ctx, network, address)
ipp, _ := netip.ParseAddrPort(address)
dc.noteDialResult(ipp.Addr(), err)
return c, err
}, fallbackDelay)
}
// TLSDialer is like Dialer but returns a func suitable for using with net/http.Transport.DialTLSContext.
@@ -695,10 +683,54 @@ func TLSDialer(fwd netx.DialFunc, dnsCache *Resolver, tlsConfigBase *tls.Config)
// DNS mechanism.
return nil, err
}
// Tell the dnsresolvecache feature (if linked in) that the
// remote IP presented a valid certificate for host. The chain
// is checked here, independently of whatever verification the
// tls.Config did during the handshake, because some callers
// (notably controlhttp, which runs Noise atop whatever
// transport it gets) deliberately tolerate invalid TLS
// certificates; an intercepted connection must not mark the
// DNS resolution as verified.
if buildfeatures.HasDNSResolveCache {
if f, ok := HookHostVerified.GetOk(); ok && certValidForHost(tlsConn.ConnectionState(), host, cfg.RootCAs) {
if ap, err := netip.ParseAddrPort(tcpConn.RemoteAddr().String()); err == nil {
f(host, ap.Addr().Unmap())
}
}
}
return tlsConn, nil
}
}
// certValidForHost reports whether cs's peer certificate chain is
// valid for host, verifying against roots if non-nil (a caller's
// explicitly configured trust anchors), else against the system
// roots. In either case the baked-in Let's Encrypt roots are also
// tried, as net/tlsdial's Config does.
func certValidForHost(cs tls.ConnectionState, host string, roots *x509.CertPool) bool {
if len(cs.PeerCertificates) == 0 {
return false
}
opts := x509.VerifyOptions{
DNSName: host,
Roots: roots, // nil means system roots
Intermediates: x509.NewCertPool(),
}
for _, cert := range cs.PeerCertificates[1:] {
opts.Intermediates.AddCert(cert)
}
if _, err := cs.PeerCertificates[0].Verify(opts); err == nil {
return true
}
if buildfeatures.HasBakedRoots {
opts.Roots = bakedroots.Get()
if _, err := cs.PeerCertificates[0].Verify(opts); err == nil {
return true
}
}
return false
}
func cloneTLSConfig(cfg *tls.Config) *tls.Config {
if cfg == nil {
return &tls.Config{}
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package dnscache
+9 -4
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package dnsfallback contains a DNS fallback mechanism
@@ -30,6 +30,7 @@ import (
"tailscale.com/health"
"tailscale.com/net/netmon"
"tailscale.com/net/netns"
"tailscale.com/net/netutil"
"tailscale.com/net/tlsdial"
"tailscale.com/tailcfg"
"tailscale.com/types/logger"
@@ -130,10 +131,14 @@ func lookup(ctx context.Context, host string, logf logger.Logf, ht *health.Track
// serverName and serverIP of are, say, "derpN.tailscale.com".
// queryName is the name being sought (e.g. "controlplane.tailscale.com"), passed as hint.
//
// ht may be nil.
// ht and netMon may be nil. If netMon is nil, a plain net.Dialer is used
// instead of a netns-aware one.
func bootstrapDNSMap(ctx context.Context, serverName string, serverIP netip.Addr, queryName string, logf logger.Logf, ht *health.Tracker, netMon *netmon.Monitor) (dnsMap, error) {
dialer := netns.NewDialer(logf, netMon)
tr := http.DefaultTransport.(*http.Transport).Clone()
var dialer netns.Dialer = new(net.Dialer)
if netMon != nil {
dialer = netns.NewDialer(logf, netMon)
}
tr := netutil.NewDefaultTransport()
tr.DisableKeepAlives = true // This transport is meant to be used once.
tr.Proxy = feature.HookProxyFromEnvironment.GetOrNil()
tr.DialContext = func(ctx context.Context, netw, addr string) (net.Conn, error) {
+14 -3
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//
// Original implementation (from same author) from which this was derived was:
@@ -52,8 +52,19 @@ func (t Tuple) DstAddr() netip.Addr {
return netip.AddrFrom16(t.dst).Unmap()
}
func (t Tuple) SrcPort() uint16 { return t.srcPort }
func (t Tuple) DstPort() uint16 { return t.dstPort }
func (t Tuple) SrcPort() uint16 { return t.srcPort }
func (t Tuple) DstPort() uint16 { return t.dstPort }
func (t Tuple) Proto() ipproto.Proto { return t.proto }
// Src returns the tuple's source endpoint as a netip.AddrPort.
func (t Tuple) Src() netip.AddrPort {
return netip.AddrPortFrom(t.SrcAddr(), t.srcPort)
}
// Dst returns the tuple's destination endpoint as a netip.AddrPort.
func (t Tuple) Dst() netip.AddrPort {
return netip.AddrPortFrom(t.DstAddr(), t.dstPort)
}
func (t Tuple) String() string {
return fmt.Sprintf("(%v %v => %v)", t.proto,
+4 -11
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package ipset provides code for creating efficient IP-in-set lookup functions
@@ -20,13 +20,6 @@ func FalseContainsIPFunc() func(ip netip.Addr) bool {
func emptySet(ip netip.Addr) bool { return false }
func bartLookup(t *bart.Table[struct{}]) func(netip.Addr) bool {
return func(ip netip.Addr) bool {
_, ok := t.Lookup(ip)
return ok
}
}
func prefixContainsLoop(addrs []netip.Prefix) func(netip.Addr) bool {
return func(ip netip.Addr) bool {
for _, p := range addrs {
@@ -81,11 +74,11 @@ func NewContainsIPFunc(addrs views.Slice[netip.Prefix]) func(ip netip.Addr) bool
}
pathForTest("bart")
// Built a bart table.
t := &bart.Table[struct{}]{}
t := &bart.Lite{}
for _, p := range addrs.All() {
t.Insert(p, struct{}{})
t.Insert(p)
}
return bartLookup(t)
return t.Contains
}
// Fast paths for 1 and 2 IPs:
if addrs.Len() == 1 {
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package memnet
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package memnet
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package memnet implements an in-memory network implementation.
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package memnet
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package netaddr is a transitional package while we finish migrating from inet.af/netaddr
-55
View File
@@ -1,55 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_captiveportal
package netcheck
import (
"context"
"time"
"tailscale.com/net/captivedetection"
"tailscale.com/tailcfg"
)
func init() {
hookStartCaptivePortalDetection.Set(startCaptivePortalDetection)
}
func startCaptivePortalDetection(ctx context.Context, rs *reportState, dm *tailcfg.DERPMap, preferredDERP int) (done <-chan struct{}, stop func()) {
c := rs.c
// NOTE(andrew): we can't simply add this goroutine to the
// `NewWaitGroupChan` below, since we don't wait for that
// waitgroup to finish when exiting this function and thus get
// a data race.
ch := make(chan struct{})
tmr := time.AfterFunc(c.captivePortalDelay(), func() {
defer close(ch)
d := captivedetection.NewDetector(c.logf)
found := d.Detect(ctx, c.NetMon, dm, preferredDERP)
rs.report.CaptivePortal.Set(found)
})
stop = func() {
// Don't cancel our captive portal check if we're
// explicitly doing a verbose netcheck.
if c.Verbose {
return
}
if tmr.Stop() {
// Stopped successfully; need to close the
// signal channel ourselves.
close(ch)
return
}
// Did not stop; do nothing and it'll finish by itself
// and close the signal channel.
}
return ch, stop
}
+157 -79
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package netcheck checks the network conditions from the current host.
@@ -39,12 +39,14 @@ import (
"tailscale.com/net/stun"
"tailscale.com/syncs"
"tailscale.com/tailcfg"
"tailscale.com/types/bools"
"tailscale.com/types/logger"
"tailscale.com/types/nettype"
"tailscale.com/types/opt"
"tailscale.com/types/views"
"tailscale.com/util/clientmetric"
"tailscale.com/util/mak"
"tailscale.com/util/testenv"
)
// Debugging and experimentation tweakables.
@@ -110,10 +112,10 @@ type Report struct {
// Empty means not checked.
PCP opt.Bool
PreferredDERP int // or 0 for unknown
RegionLatency map[int]time.Duration // keyed by DERP Region ID
RegionV4Latency map[int]time.Duration // keyed by DERP Region ID
RegionV6Latency map[int]time.Duration // keyed by DERP Region ID
PreferredDERP tailcfg.DERPRegionID // or 0 for unknown
RegionLatency RegionLatency // keyed by DERP Region ID
RegionV4Latency RegionLatency // keyed by DERP Region ID
RegionV6Latency RegionLatency // keyed by DERP Region ID
GlobalV4Counters map[netip.AddrPort]int // number of times the endpoint was observed
GlobalV6Counters map[netip.AddrPort]int // number of times the endpoint was observed
@@ -183,6 +185,34 @@ func (r *Report) Clone() *Report {
return &r2
}
// RegionLatency is a map of DERP region IDs associated with their measured latencies.
type RegionLatency map[tailcfg.DERPRegionID]time.Duration
// Compare compares the latency of the regions i and j. It returns
//
// -1 if region i is contained in lat, but region j is missing
// +1 if region j is contained in lat, but region i is missing
//
// Otherwise, it returns
//
// -1 if the latency of region i is less than the latency of region j
// +1 if the latency of region i is greater than the latency of region j
//
// If both latency values are equal, then it breaks ties by region ID and returns
//
// -1 if i is less than j
// 0 if i equals j
// +1 if i is greater than j
func (lat RegionLatency) Compare(i, j tailcfg.DERPRegionID) int {
iLat, iOK := lat[i]
jLat, jOK := lat[j]
return cmp.Or(
bools.Compare(!iOK, !jOK), // defined things sort first
cmp.Compare(iLat, jLat), // sort by latency
cmp.Compare(i, j), // break ties by region ID
)
}
// Client generates Reports describing the result of both passive and active
// network configuration probing. It provides two different modes of report, a
// full report (see MakeNextReportFull) and a more lightweight incremental
@@ -229,11 +259,10 @@ type Client struct {
// if non-zero, force this DERP region to be preferred in all reports where
// the DERP is found to be reachable.
ForcePreferredDERP int
ForcePreferredDERP tailcfg.DERPRegionID
// For tests
testEnoughRegions int
testCaptivePortalDelay time.Duration
testEnoughRegions int
mu syncs.Mutex // guards following
nextFull bool // do a full region scan, even if last != nil
@@ -256,14 +285,6 @@ func (c *Client) enoughRegions() int {
return 3
}
func (c *Client) captivePortalDelay() time.Duration {
if c.testCaptivePortalDelay > 0 {
return c.testCaptivePortalDelay
}
// Chosen semi-arbitrarily
return 200 * time.Millisecond
}
func (c *Client) logf(format string, a ...any) {
if c.Logf != nil {
c.Logf(format, a...)
@@ -387,7 +408,7 @@ type probePlan map[string][]probe
// sortRegions returns the regions of dm first sorted
// from fastest to slowest (based on the 'last' report),
// end in regions that have no data.
func sortRegions(dm *tailcfg.DERPMap, last *Report, preferredDERP int) (prev []*tailcfg.DERPRegion) {
func sortRegions(dm *tailcfg.DERPMap, last *Report, preferredDERP tailcfg.DERPRegionID) (prev []*tailcfg.DERPRegion) {
prev = make([]*tailcfg.DERPRegion, 0, len(dm.Regions))
for _, reg := range dm.Regions {
if reg.NoMeasureNoHome {
@@ -431,7 +452,7 @@ const numIncrementalRegions = 3
// TODO(raggi): change from "preferred DERP" from a historical report to "home
// DERP" as in what DERP is the current home connection, this would further
// reduce flap events.
func makeProbePlan(dm *tailcfg.DERPMap, ifState *netmon.State, last *Report, preferredDERP int) (plan probePlan) {
func makeProbePlan(dm *tailcfg.DERPMap, ifState *netmon.State, last *Report, preferredDERP tailcfg.DERPRegionID) (plan probePlan) {
if last == nil || len(last.RegionLatency) == 0 {
return makeProbePlanInitial(dm, ifState)
}
@@ -545,7 +566,7 @@ func makeProbePlanInitial(dm *tailcfg.DERPMap, ifState *netmon.State) (plan prob
var p4 []probe
var p6 []probe
for try := 0; try < 3; try++ {
for try := range 3 {
n := reg.Nodes[try%len(reg.Nodes)]
delay := time.Duration(try) * defaultInitialRetransmitTime
if n.IPv4 != "none" && ((ifState.HaveV4 && nodeMight4(n)) || n.IsTestNode()) {
@@ -610,7 +631,7 @@ func (rs *reportState) anyUDP() bool {
return rs.report.UDP
}
func (rs *reportState) haveRegionLatency(regionID int) bool {
func (rs *reportState) haveRegionLatency(regionID tailcfg.DERPRegionID) bool {
rs.mu.Lock()
defer rs.mu.Unlock()
_, ok := rs.report.RegionLatency[regionID]
@@ -686,6 +707,9 @@ func (rs *reportState) addNodeLatency(node *tailcfg.DERPNode, ipp netip.AddrPort
switch {
case ipp.Addr().Is6():
// Receiving a STUN response proves that the corresponding probe was
// sent, even if the response races SendPacket returning in runProbe.
ret.IPv6CanSend = true
updateLatency(ret.RegionV6Latency, node.RegionID, d)
ret.IPv6 = true
ret.GlobalV6 = ipp
@@ -693,6 +717,7 @@ func (rs *reportState) addNodeLatency(node *tailcfg.DERPNode, ipp netip.AddrPort
// TODO: track MappingVariesByDestIP for IPv6
// too? Would be sad if so, but who knows.
case ipp.Addr().Is4():
ret.IPv4CanSend = true
updateLatency(ret.RegionV4Latency, node.RegionID, d)
ret.IPv4 = true
mak.Set(&ret.GlobalV4Counters, ipp, ret.GlobalV4Counters[ipp]+1)
@@ -747,9 +772,9 @@ func (rs *reportState) probePortMapServices() {
func newReport() *Report {
return &Report{
RegionLatency: make(map[int]time.Duration),
RegionV4Latency: make(map[int]time.Duration),
RegionV6Latency: make(map[int]time.Duration),
RegionLatency: make(map[tailcfg.DERPRegionID]time.Duration),
RegionV4Latency: make(map[tailcfg.DERPRegionID]time.Duration),
RegionV6Latency: make(map[tailcfg.DERPRegionID]time.Duration),
}
}
@@ -764,7 +789,7 @@ type GetReportOpts struct {
//
// If no communication with that region has occurred, or it occurred
// too far in the past, this function should return the zero time.
GetLastDERPActivity func(int) time.Time
GetLastDERPActivity func(tailcfg.DERPRegionID) time.Time
// OnlyTCP443 constrains netcheck reporting to measurements over TCP port
// 443.
OnlyTCP443 bool
@@ -774,20 +799,28 @@ type GetReportOpts struct {
// getLastDERPActivity calls o.GetLastDERPActivity if both o and
// o.GetLastDERPActivity are non-nil; otherwise it returns the zero time.
func (o *GetReportOpts) getLastDERPActivity(region int) time.Time {
func (o *GetReportOpts) getLastDERPActivity(region tailcfg.DERPRegionID) time.Time {
if o == nil || o.GetLastDERPActivity == nil {
return time.Time{}
}
return o.GetLastDERPActivity(region)
}
func (c *Client) SetForcePreferredDERP(region int) {
func (c *Client) SetForcePreferredDERP(region tailcfg.DERPRegionID) {
c.mu.Lock()
defer c.mu.Unlock()
c.ForcePreferredDERP = region
}
var hookStartCaptivePortalDetection feature.Hook[func(ctx context.Context, rs *reportState, dm *tailcfg.DERPMap, preferredDERP int) (<-chan struct{}, func())]
// HookStartCaptivePortalDetection, if set, is called by GetReport to
// asynchronously start captive portal detection during a full (non-incremental)
// netcheck. It is set at init time by the optional
// tailscale.com/feature/captiveportal/netcheckhook package.
//
// The returned done channel is closed when detection has finished (and
// setCaptivePortal has been called with the result, if it ran); the returned
// stop function cancels a detection that has not yet started.
var HookStartCaptivePortalDetection feature.Hook[func(ctx context.Context, c *Client, dm *tailcfg.DERPMap, preferredDERP tailcfg.DERPRegionID, setCaptivePortal func(bool)) (done <-chan struct{}, stop func())]
// GetReport gets a report. The 'opts' argument is optional and can be nil.
// Callers are discouraged from passing a ctx with an arbitrary deadline as this
@@ -846,13 +879,13 @@ func (c *Client) GetReport(ctx context.Context, dm *tailcfg.DERPMap, opts *GetRe
// in captive portal detection and DERP flapping suppression. Ideally this would
// be the current active home DERP rather than the last report preferred DERP,
// but only the latter is presently available.
var preferredDERP int
var preferredDERP tailcfg.DERPRegionID
if last != nil {
preferredDERP = last.PreferredDERP
}
doFull := false
if c.nextFull || now.Sub(c.lastFull) > 5*time.Minute {
if c.nextFull || now.Sub(c.lastFull) > fullReportInterval {
doFull = true
}
// If the last report had a captive portal and reported no UDP access,
@@ -903,8 +936,10 @@ func (c *Client) GetReport(ctx context.Context, dm *tailcfg.DERPMap, opts *GetRe
go rs.probePortMapServices()
}
// Without NAT traversal, we never send STUN: we behave as if UDP were
// blocked and measure DERP latency over HTTPS (and ICMP) instead.
var plan probePlan
if opts == nil || !opts.OnlyTCP443 {
if buildfeatures.HasNATTraversal && (opts == nil || !opts.OnlyTCP443) {
plan = makeProbePlan(dm, ifState, last, preferredDERP)
}
@@ -914,21 +949,26 @@ func (c *Client) GetReport(ctx context.Context, dm *tailcfg.DERPMap, opts *GetRe
captivePortalDone := syncs.ClosedChan()
captivePortalStop := func() {}
if buildfeatures.HasCaptivePortal && !rs.incremental && !onlySTUN {
start := hookStartCaptivePortalDetection.Get()
captivePortalDone, captivePortalStop = start(ctx, rs, dm, preferredDERP)
if start, ok := HookStartCaptivePortalDetection.GetOk(); ok {
captivePortalDone, captivePortalStop = start(ctx, c, dm, preferredDERP, func(found bool) {
rs.report.CaptivePortal.Set(found)
})
}
}
wg := syncs.NewWaitGroupChan()
wg.Add(len(plan))
for _, probeSet := range plan {
setCtx, cancelSet := context.WithCancel(ctx)
go func(probeSet []probe) {
for _, probe := range probeSet {
go rs.runProbe(setCtx, dm, probe, cancelSet)
}
<-setCtx.Done()
wg.Decr()
}(probeSet)
if buildfeatures.HasNATTraversal {
wg.Add(len(plan))
for _, probeSet := range plan {
setCtx, cancelSet := context.WithCancel(ctx)
go func(probeSet []probe) {
for _, probe := range probeSet {
go rs.runProbe(setCtx, dm, probe, cancelSet)
}
<-setCtx.Done()
wg.Decr()
}(probeSet)
}
}
stunTimer := time.NewTimer(stunProbeTimeout)
@@ -964,24 +1004,25 @@ func (c *Client) GetReport(ctx context.Context, dm *tailcfg.DERPMap, opts *GetRe
var wg sync.WaitGroup
var need []*tailcfg.DERPRegion
for rid, reg := range dm.Regions {
if !rs.haveRegionLatency(rid) && regionHasDERPNode(reg) && !reg.Avoid && !reg.NoMeasureNoHome {
if !rs.haveRegionLatency(tailcfg.DERPRegionID(rid)) && regionHasDERPNode(reg) && !reg.Avoid && !reg.NoMeasureNoHome {
need = append(need, reg)
}
}
if len(need) > 0 {
if opts == nil || !opts.OnlyTCP443 {
// Without UDP to peers, DERP is our only transport and its
// latency is measured over HTTPS alone; ICMP is not worth
// linking in just for that.
if buildfeatures.HasUDPTransport && (opts == nil || !opts.OnlyTCP443) {
// Kick off ICMP in parallel to HTTPS checks; we don't
// reuse the same WaitGroup for those probes because we
// need to close the underlying Pinger after a timeout
// or when all ICMP probes are done, regardless of
// whether the HTTPS probes have finished.
wg.Add(1)
go func() {
defer wg.Done()
wg.Go(func() {
if err := c.measureAllICMPLatency(ctx, rs, need); err != nil {
c.logf("[v1] measureAllICMPLatency: %v", err)
}
}()
})
}
wg.Add(len(need))
c.logf("netcheck: UDP is blocked, trying HTTPS")
@@ -1027,7 +1068,7 @@ func (c *Client) finishAndStoreReport(rs *reportState, dm *tailcfg.DERPMap) *Rep
report := rs.report.Clone()
rs.mu.Unlock()
c.addReportHistoryAndSetPreferredDERP(rs, report, dm.View())
c.addReportHistoryAndSetPreferredDERP(rs, report, dm.View(), c.timeNow())
c.logConciseReport(report, dm)
return report
@@ -1072,9 +1113,7 @@ func (c *Client) runHTTPOnlyChecks(ctx context.Context, last *Report, rs *report
if len(rg.Nodes) == 0 {
continue
}
wg.Add(1)
go func() {
defer wg.Done()
wg.Go(func() {
node := rg.Nodes[0]
req, _ := http.NewRequestWithContext(ctx, "HEAD", "https://"+node.HostName+"/derp/probe", nil)
// One warm-up one to get HTTP connection set
@@ -1099,7 +1138,7 @@ func (c *Client) runHTTPOnlyChecks(ctx context.Context, last *Report, rs *report
}
d := c.timeNow().Sub(t0)
rs.addNodeLatency(node, netip.AddrPort{}, d)
}()
})
}
wg.Wait()
return nil
@@ -1332,6 +1371,12 @@ func (c *Client) timeNow() time.Time {
}
const (
// fullReportInterval is the maximum time between full netcheck reports.
// Once this long has elapsed since the last full report, the next GetReport
// re-probes every DERP region rather than only the home and fastest regions
// (see GetReport). It also informs retention window for report history
// (c.prev).
fullReportInterval = 5 * time.Minute
// preferredDERPAbsoluteDiff specifies the minimum absolute difference
// in latencies between two DERP regions that would cause a node to
// switch its PreferredDERP ("home DERP"). This ensures that if a node
@@ -1356,44 +1401,45 @@ const (
PreferredDERPKeepAliveTimeout = 2 * derp.KeepAlive
)
// addReportHistoryAndSetPreferredDERP adds r to the set of recent Reports
// and mutates r.PreferredDERP to contain the best recent one.
func (c *Client) addReportHistoryAndSetPreferredDERP(rs *reportState, r *Report, dm tailcfg.DERPMapView) {
c.mu.Lock()
defer c.mu.Unlock()
var prevDERP int
if c.last != nil {
prevDERP = c.last.PreferredDERP
}
// addReportAndPruneExpired adds r to the set of recent Reports, and drops
// reports that are outside of the retention window.
func (c *Client) addReportAndPruneExpired(now time.Time, r *Report) {
if c.prev == nil {
c.prev = map[time.Time]*Report{}
}
now := c.timeNow()
r.Now = now.UTC()
c.prev[now] = r
c.last = r
const maxAge = 5 * time.Minute
// region ID => its best recent latency in last maxAge
bestRecent := map[int]time.Duration{}
for t, pr := range c.prev {
// maxAge is the retention window for report history, based on fullReportInterval
// to make sure that at least one full report is always retained.
const maxAge = fullReportInterval + ReportTimeout
for t := range c.prev {
if now.Sub(t) > maxAge {
delete(c.prev, t)
continue
}
for regionID, d := range pr.RegionLatency {
if bd, ok := bestRecent[regionID]; !ok || d < bd {
bestRecent[regionID] = d
}
}
}
}
// addReportHistoryAndSetPreferredDERP adds r to the set of recent Reports
// and mutates r.PreferredDERP to contain the best recent one.
func (c *Client) addReportHistoryAndSetPreferredDERP(rs *reportState, r *Report, dm tailcfg.DERPMapView, now time.Time) {
c.mu.Lock()
defer c.mu.Unlock()
var prevDERP tailcfg.DERPRegionID
if c.last != nil {
prevDERP = c.last.PreferredDERP
}
// Add report to history, enforce retention window, then take the best (lowest)
// latency seen per region across what remains.
c.addReportAndPruneExpired(now, r)
bestRecent := c.bestRecentLatencyLocked()
// Scale each region's best latency by any provided scores from the
// DERPMap, for use in comparison below.
var scores views.Map[int, float64]
var scores views.Map[tailcfg.DERPRegionID, float64]
if hp := dm.HomeParams(); hp.Valid() {
scores = hp.RegionScore()
}
@@ -1484,7 +1530,39 @@ func (c *Client) addReportHistoryAndSetPreferredDERP(rs *reportState, r *Report,
}
}
func updateLatency(m map[int]time.Duration, regionID int, d time.Duration) {
// bestRecentLatencyLocked returns the lowest latency seen per DERP region across
// the reports currently retained in history (c.prev), keyed by region ID. These
// latencies are used for determining preferred DERP and suggesting an exit node.
func (c *Client) bestRecentLatencyLocked() map[tailcfg.DERPRegionID]time.Duration {
best := make(map[tailcfg.DERPRegionID]time.Duration)
for _, pr := range c.prev {
for regionID, d := range pr.RegionLatency {
if bd, ok := best[regionID]; !ok || d < bd {
best[regionID] = d
}
}
}
return best
}
// RecentRegionLatency returns the lowest latency seen per DERP region over the
// recent history window, keyed by region ID.
func (c *Client) RecentRegionLatency() map[tailcfg.DERPRegionID]time.Duration {
c.mu.Lock()
defer c.mu.Unlock()
return c.bestRecentLatencyLocked()
}
// AddReportHistoryForTest records r in the client's recent-report history
// (prev/last) as if GetReport had produced it at time now, and recomputes
// r.PreferredDERP from that history.
func (c *Client) AddReportHistoryForTest(dm *tailcfg.DERPMap, r *Report, now time.Time) {
testenv.AssertInTest()
rs := &reportState{c: c, start: now}
c.addReportHistoryAndSetPreferredDERP(rs, r, dm.View(), now)
}
func updateLatency(m map[tailcfg.DERPRegionID]time.Duration, regionID tailcfg.DERPRegionID, d time.Duration) {
if prev, ok := m[regionID]; !ok || d < prev {
m[regionID] = d
}
@@ -1684,7 +1762,7 @@ func regionHasDERPNode(r *tailcfg.DERPRegion) bool {
return false
}
func maxDurationValue(m map[int]time.Duration) (max time.Duration) {
func maxDurationValue(m map[tailcfg.DERPRegionID]time.Duration) (max time.Duration) {
for _, v := range m {
if v > max {
max = v
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package netcheck
+3 -3
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package neterror classifies network errors.
@@ -50,8 +50,8 @@ var packetWasTruncated func(error) bool // non-nil on Windows at least
// size and an error are returned (as per the partial fix for golang/go#14074).
// If the WSAEMSGSIZE error is returned, then we ignore the error to get
// semantics similar to the POSIX operating systems. One caveat is that it
// appears that the source address is not returned when WSAEMSGSIZE occurs, but
// we do not currently look at the source address.
// appears that the source address is not returned when WSAEMSGSIZE occurs, so
// a caller can't check where a truncated datagram came from.
func PacketWasTruncated(err error) bool {
if packetWasTruncated == nil {
return false
+20
View File
@@ -0,0 +1,20 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build js || wasip1 || wasm
package neterror
import (
"errors"
"io"
"io/fs"
)
// Reports whether err resulted from reading or writing to a closed or broken pipe.
func IsClosedPipeError(err error) bool {
// Libraries may also return root errors like fs.ErrClosed/io.ErrClosedPipe
// due to a closed socket.
return errors.Is(err, fs.ErrClosed) ||
errors.Is(err, io.ErrClosedPipe)
}
+2 -3
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package neterror
@@ -12,8 +12,7 @@ import (
func init() {
shouldDisableUDPGSO = func(err error) bool {
var serr *os.SyscallError
if errors.As(err, &serr) {
if serr, ok := errors.AsType[*os.SyscallError](err); ok {
// EIO is returned by udp_send_skb() if the device driver does not
// have tx checksumming enabled, which is a hard requirement of
// UDP_SEGMENT. See:
+24
View File
@@ -0,0 +1,24 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build plan9
package neterror
import (
"errors"
"io"
"io/fs"
"strings"
)
// Reports whether err resulted from reading or writing to a closed or broken pipe.
func IsClosedPipeError(err error) bool {
// Libraries may also return root errors like fs.ErrClosed/io.ErrClosedPipe
// due to a closed socket.
// For a raw syscall error, check for error string containing "closed pipe",
// per the note set by the system: https://9p.io/magic/man2html/2/pipe
return errors.Is(err, fs.ErrClosed) ||
errors.Is(err, io.ErrClosedPipe) ||
strings.Contains(err.Error(), "closed pipe")
}
+32
View File
@@ -0,0 +1,32 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !plan9 && !js && !wasip1 && !wasm
package neterror
import (
"errors"
"io"
"io/fs"
"runtime"
"syscall"
)
// Reports whether err resulted from reading or writing to a closed or broken pipe.
func IsClosedPipeError(err error) bool {
// 232 is Windows error code ERROR_NO_DATA, "The pipe is being closed".
if runtime.GOOS == "windows" && errors.Is(err, syscall.Errno(232)) {
return true
}
// EPIPE/ENOTCONN are common errors when a send fails due to a closed
// socket. There is some platform and version inconsistency in which
// error is returned, but the meaning is the same.
// Libraries may also return root errors like fs.ErrClosed/io.ErrClosedPipe
// due to a closed socket.
return errors.Is(err, syscall.EPIPE) ||
errors.Is(err, syscall.ENOTCONN) ||
errors.Is(err, fs.ErrClosed) ||
errors.Is(err, io.ErrClosedPipe)
}
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package neterror
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package netkernelconf contains code for checking kernel netdev config.
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !linux || android
+2 -2
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux && !android
@@ -48,7 +48,7 @@ func CheckUDPGROForwarding(tunInterface, defaultRouteInterface string) (warn, er
}
defaultHasRxWant, ok := defaultFeatures[rxWantFeature]
if !ok {
// unlikely the feature is nonexistant with txFeature in the TUN driver
// unlikely the feature is nonexistent with txFeature in the TUN driver
// being added to the kernel later than rxWantFeature, but let's be sure
return nil, nil
}
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package netknob has Tailscale network knobs.
+3 -4
View File
@@ -1,11 +1,10 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Common code for FreeBSD. This might also work on other
// BSD systems (e.g. OpenBSD) but has not been tested.
// Common code for FreeBSD and OpenBSD.
// Not used on iOS or macOS. See defaultroute_darwin.go.
//go:build freebsd
//go:build freebsd || openbsd
package netmon
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build darwin || ios
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package netmon
+85 -110
View File
@@ -1,13 +1,11 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package netmon
import (
"bytes"
"log"
"net/netip"
"os/exec"
"sync/atomic"
"go4.org/mem"
@@ -19,6 +17,7 @@ import (
var (
lastKnownDefaultRouteIfName syncs.AtomicValue[string]
lastKnownDefaultGateway syncs.AtomicValue[string]
)
var procNetRoutePath = "/proc/net/route"
@@ -42,123 +41,99 @@ ens18 00000000 0100000A 0003 0 0 0 00000000
ens18 0000000A 00000000 0001 0 0 0 0000FFFF 0 0 0
*/
func likelyHomeRouterIPAndroid() (ret netip.Addr, myIP netip.Addr, ok bool) {
if procNetRouteErr.Load() {
// If we failed to read /proc/net/route previously, don't keep trying.
return likelyHomeRouterIPHelper()
}
lineNum := 0
var f []mem.RO
for lr := range lineiter.File(procNetRoutePath) {
line, err := lr.Value()
if err != nil {
procNetRouteErr.Store(true)
return likelyHomeRouterIP()
}
lineNum++
if lineNum == 1 {
// Skip header line.
continue
}
if lineNum > maxProcNetRouteRead {
break
}
f = mem.AppendFields(f[:0], mem.B(line))
if len(f) < 4 {
continue
}
gwHex, flagsHex := f[2], f[3]
flags, err := mem.ParseUint(flagsHex, 16, 16)
if err != nil {
continue // ignore error, skip line and keep going
}
if flags&(unix.RTF_UP|unix.RTF_GATEWAY) != unix.RTF_UP|unix.RTF_GATEWAY {
continue
}
ipu32, err := mem.ParseUint(gwHex, 16, 32)
if err != nil {
continue // ignore error, skip line and keep going
}
ip := netaddr.IPv4(byte(ipu32), byte(ipu32>>8), byte(ipu32>>16), byte(ipu32>>24))
if ip.IsPrivate() {
ret = ip
break
if gwStr := lastKnownDefaultGateway.Load(); gwStr != "" {
if ip, err := netip.ParseAddr(gwStr); err == nil {
return ip, netip.Addr{}, true
}
}
if ret.IsValid() {
// Try to get the local IP of the interface associated with
// this route to short-circuit finding the IP associated with
// this gateway. This isn't fatal if it fails.
if len(f) > 0 && !disableLikelyHomeRouterIPSelf() {
ForeachInterface(func(ni Interface, pfxs []netip.Prefix) {
// Ensure this is the same interface
if !f[0].EqualString(ni.Name) {
return
}
// Fall back to /proc/net/route for early startup before
// NetworkChangeCallback has fired, or if the cached gateway
// was empty (e.g., cellular with no private gateway).
if !procNetRouteErr.Load() {
lineNum := 0
var f []mem.RO
for lr := range lineiter.File(procNetRoutePath) {
line, err := lr.Value()
if err != nil {
procNetRouteErr.Store(true)
return likelyHomeRouterIP()
}
// Find the first IPv4 address and use it.
for _, pfx := range pfxs {
if addr := pfx.Addr(); addr.Is4() {
myIP = addr
break
lineNum++
if lineNum == 1 {
// Skip header line.
continue
}
if lineNum > maxProcNetRouteRead {
break
}
f = mem.AppendFields(f[:0], mem.B(line))
if len(f) < 4 {
continue
}
gwHex, flagsHex := f[2], f[3]
flags, err := mem.ParseUint(flagsHex, 16, 16)
if err != nil {
continue // ignore error, skip line and keep going
}
if flags&(unix.RTF_UP|unix.RTF_GATEWAY) != unix.RTF_UP|unix.RTF_GATEWAY {
continue
}
ipu32, err := mem.ParseUint(gwHex, 16, 32)
if err != nil {
continue // ignore error, skip line and keep going
}
ip := netaddr.IPv4(byte(ipu32), byte(ipu32>>8), byte(ipu32>>16), byte(ipu32>>24))
if ip.IsPrivate() {
ret = ip
break
}
}
if ret.IsValid() {
// Try to get the local IP of the interface associated with
// this route to short-circuit finding the IP associated with
// this gateway. This isn't fatal if it fails.
if len(f) > 0 && !disableLikelyHomeRouterIPSelf() {
ForeachInterface(func(ni Interface, pfxs []netip.Prefix) {
// Ensure this is the same interface
if !f[0].EqualString(ni.Name) {
return
}
}
})
}
return ret, myIP, true
}
if lineNum >= maxProcNetRouteRead {
// If we went over our line limit without finding an answer, assume
// we're a big fancy Linux router (or at least not a home system)
// and set the error bit so we stop trying this in the future (and wasting CPU).
// See https://github.com/tailscale/tailscale/issues/7621.
//
// Remember that "likelyHomeRouterIP" exists purely to find the port
// mapping service (UPnP, PMP, PCP) often present on a home router. If we hit
// the route (line) limit without finding an answer, we're unlikely to ever
// find one in the future.
procNetRouteErr.Store(true)
// Find the first IPv4 address and use it.
for _, pfx := range pfxs {
if addr := pfx.Addr(); addr.Is4() {
myIP = addr
break
}
}
})
}
return ret, myIP, true
}
if lineNum >= maxProcNetRouteRead {
// If we went over our line limit without finding an answer, assume
// we're a big fancy Linux router (or at least not a home system)
// and set the error bit so we stop trying this in the future (and wasting CPU).
// See https://github.com/tailscale/tailscale/issues/7621.
//
// Remember that "likelyHomeRouterIP" exists purely to find the port
// mapping service (UPnP, PMP, PCP) often present on a home router. If we hit
// the route (line) limit without finding an answer, we're unlikely to ever
// find one in the future.
procNetRouteErr.Store(true)
}
}
return netip.Addr{}, netip.Addr{}, false
}
// Android apps don't have permission to read /proc/net/route, at
// least on Google devices and the Android emulator.
func likelyHomeRouterIPHelper() (ret netip.Addr, _ netip.Addr, ok bool) {
cmd := exec.Command("/system/bin/ip", "route", "show", "table", "0")
out, err := cmd.StdoutPipe()
if err != nil {
return
// UpdateLastKnownDefaultGateway is called by libtailscale in the Android app when
// the connectivity manager provides an updated default gateway IP from LinkProperties.
func UpdateLastKnownDefaultGateway(ipStr string) {
if old := lastKnownDefaultGateway.Swap(ipStr); old != ipStr {
log.Printf("defaultgateway: update from Android, gateway = %s (was %s)", ipStr, old)
}
if err := cmd.Start(); err != nil {
log.Printf("interfaces: running /system/bin/ip: %v", err)
return
}
// Search for line like "default via 10.0.2.2 dev radio0 table 1016 proto static mtu 1500 "
for lr := range lineiter.Reader(out) {
line, err := lr.Value()
if err != nil {
break
}
const pfx = "default via "
if !mem.HasPrefix(mem.B(line), mem.S(pfx)) {
continue
}
line = line[len(pfx):]
sp := bytes.IndexByte(line, ' ')
if sp == -1 {
continue
}
ipb := line[:sp]
if ip, err := netip.ParseAddr(string(ipb)); err == nil && ip.Is4() {
ret = ip
log.Printf("interfaces: found Android default route %v", ip)
}
}
cmd.Process.Kill()
cmd.Wait()
return ret, netip.Addr{}, ret.IsValid()
}
// UpdateLastKnownDefaultRouteInterface is called by libtailscale in the Android app when
+2 -2
View File
@@ -1,10 +1,10 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Common code for FreeBSD and Darwin. This might also work on other
// BSD systems (e.g. OpenBSD) but has not been tested.
//go:build darwin || freebsd
//go:build darwin || freebsd || openbsd
package netmon
@@ -1,9 +1,9 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// This might work on other BSDs, but only tested on FreeBSD.
// FreeBSD and OpenBSD routing table functions.
//go:build freebsd
//go:build freebsd || openbsd
package netmon
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package netmon
+2 -2
View File
@@ -1,7 +1,7 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !linux && !windows && !darwin && !freebsd && !android
//go:build !linux && !windows && !darwin && !freebsd && !android && !openbsd
package netmon
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !android
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package netmon
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package netmon
+123 -49
View File
@@ -1,9 +1,9 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package monitor provides facilities for monitoring network
// interface and route changes. It primarily exists to know when
// portable devices move between different networks.
// Package netmon provides facilities for monitoring network interface and
// route changes. It primarily exists to know when portable devices move
// between different networks.
package netmon
import (
@@ -13,6 +13,7 @@ import (
"net/netip"
"runtime"
"slices"
"strings"
"sync"
"time"
@@ -31,6 +32,15 @@ import (
// us check the wall time sooner than this.
const pollWallTimeInterval = 15 * time.Second
// majorTimeJumpThreshold is the minimum sleep duration that warrants
// treating a time jump as a major event requiring socket rebinding,
// even if the interface state appears unchanged. After a long sleep,
// NAT mappings are likely stale and DHCP leases may have expired
// (the renewal happens after wake, so local state may not yet reflect it).
// Short sleeps (e.g., macOS DarkWake maintenance cycles of ~55s) should
// not trigger rebinding if the network state is unchanged.
const majorTimeJumpThreshold = 10 * time.Minute
// message represents a message returned from an osMon.
type message interface {
// Ignore is whether we should ignore this message.
@@ -67,18 +77,18 @@ type Monitor struct {
stop chan struct{} // closed on Stop
static bool // static Monitor that doesn't actually monitor
mu syncs.Mutex // guards all following fields
cbs set.HandleSet[ChangeFunc]
ifState *State
gwValid bool // whether gw and gwSelfIP are valid
gw netip.Addr // our gateway's IP
gwSelfIP netip.Addr // our own IP address (that corresponds to gw)
started bool
closed bool
goroutines sync.WaitGroup
wallTimer *time.Timer // nil until Started; re-armed AfterFunc per tick
lastWall time.Time
timeJumped bool // whether we need to send a changed=true after a big time jump
mu syncs.Mutex // guards all following fields
cbs set.HandleSet[ChangeFunc]
ifState *State
gwValid bool // whether gw and gwSelfIP are valid
gw netip.Addr // our gateway's IP
gwSelfIP netip.Addr // our own IP address (that corresponds to gw)
started bool
closed bool
goroutines sync.WaitGroup
wallTimer *time.Timer // nil until Started; re-armed AfterFunc per tick
lastWall time.Time
jumpDuration time.Duration // wall-clock time elapsed during detected time jump; 0 if no time jump observed since reset
}
// ChangeFunc is a callback function registered with Monitor that's called when the
@@ -97,10 +107,12 @@ type ChangeDelta struct {
// It is always non-nil.
new *State
// TimeJumped is whether there was a big jump in wall time since the last
// time we checked. This is a hint that a sleeping device might have
// come out of sleep.
TimeJumped bool
// JumpDuration is non-zero when a wall-clock time jump was detected,
// indicating the machine likely just woke from sleep. It is approximately
// how long the machine was asleep (the wall-clock delta since the last
// check, not an exact sleep measurement). Use TimeJumped() to check
// whether a time jump occurred.
JumpDuration time.Duration
DefaultRouteInterface string
@@ -121,19 +133,28 @@ type ChangeDelta struct {
RebindLikelyRequired bool
}
// TimeJumped reports whether a wall-clock time jump was detected,
// indicating the machine likely just woke from sleep. When true,
// JumpDuration contains the approximate duration.
func (cd *ChangeDelta) TimeJumped() bool {
return cd.JumpDuration > 0
}
// CurrentState returns the current (new) state after the change.
func (cd *ChangeDelta) CurrentState() *State {
return cd.new
}
// NewChangeDelta builds a ChangeDelta and eagerly computes the cached fields.
// jumpDuration, if non-zero, indicates a wall-clock time jump was detected
// (the machine likely woke from sleep) and is the approximate duration of the jump.
// forceViability, if true, forces DefaultInterfaceMaybeViable to be true regardless of the
// actual state of the default interface. This is useful in testing.
func NewChangeDelta(old, new *State, timeJumped bool, forceViability bool) (*ChangeDelta, error) {
func NewChangeDelta(old, new *State, jumpDuration time.Duration, forceViability bool) (*ChangeDelta, error) {
cd := ChangeDelta{
old: old,
new: new,
TimeJumped: timeJumped,
old: old,
new: new,
JumpDuration: jumpDuration,
}
if cd.new == nil {
@@ -165,10 +186,18 @@ func NewChangeDelta(old, new *State, timeJumped bool, forceViability bool) (*Cha
cd.DefaultInterfaceMaybeViable = true
}
// Compute rebind requirement. The default interface needs to be viable and
// Compute rebind requirement. The default interface needs to be viable and
// one of the other conditions needs to be true.
//
// Short time jumps (e.g., macOS DarkWake maintenance cycles of ~55s) are
// excluded — if the network state is unchanged after a brief sleep, there's
// no reason to rebind. However, a major time jump (over majorTimeJumpThreshold)
// warrants a rebind even if the local state looks the same, because NAT
// mappings are likely stale and DHCP leases may have changed (the renewal
// happens after wake, so local state may not yet reflect it).
majorTimeJump := cd.JumpDuration >= majorTimeJumpThreshold
cd.RebindLikelyRequired = (cd.old == nil ||
cd.TimeJumped ||
majorTimeJump ||
cd.DefaultInterfaceChanged ||
cd.InterfaceIPsChanged ||
cd.IsLessExpensive ||
@@ -181,7 +210,39 @@ func NewChangeDelta(old, new *State, timeJumped bool, forceViability bool) (*Cha
// StateDesc returns a description of the old and new states for logging.
func (cd *ChangeDelta) StateDesc() string {
return fmt.Sprintf("old: %v new: %v", cd.old, cd.new)
var sb strings.Builder
fmt.Fprintf(&sb, "old: %v new: %v", cd.old, cd.new)
if cd.old != nil && cd.new != nil {
if diff := cd.old.InterfaceDiff(cd.new); diff != "" {
fmt.Fprintf(&sb, " diff: %s", diff)
}
}
if cd.RebindLikelyRequired {
var reasons []string
if cd.old == nil {
reasons = append(reasons, "initial-state")
}
if cd.TimeJumped() {
reasons = append(reasons, fmt.Sprintf("time-jumped(%v)", cd.JumpDuration.Round(time.Second)))
}
if cd.DefaultInterfaceChanged {
reasons = append(reasons, "default-if-changed")
}
if cd.InterfaceIPsChanged {
reasons = append(reasons, "ips-changed")
}
if cd.IsLessExpensive {
reasons = append(reasons, "less-expensive")
}
if cd.HasPACOrProxyConfigChanged {
reasons = append(reasons, "pac-proxy-changed")
}
if cd.AvailableProtocolsChanged {
reasons = append(reasons, "protocols-changed")
}
fmt.Fprintf(&sb, " rebind-reason=[%s]", strings.Join(reasons, ","))
}
return sb.String()
}
// InterfaceIPDisappeared reports whether the given IP address exists on any interface
@@ -193,7 +254,7 @@ func (cd *ChangeDelta) InterfaceIPDisappeared(ip netip.Addr) bool {
if cd.new == nil && cd.old.HasIP(ip) {
return true
}
return cd.new.HasIP(ip) && !cd.old.HasIP(ip)
return cd.old.HasIP(ip) && !cd.new.HasIP(ip)
}
// AnyInterfaceUp reports whether any interfaces are up in the new state.
@@ -201,17 +262,12 @@ func (cd *ChangeDelta) AnyInterfaceUp() bool {
if cd.new == nil {
return false
}
for _, ifi := range cd.new.Interface {
if ifi.IsUp() {
return true
}
}
return false
return cd.new.AnyInterfaceUp()
}
// isInterestingInterfaceChange reports whether any interfaces have changed in a meaningful way.
// This excludes interfaces that are not interesting per IsInterestingInterface and
// filters out changes to interface IPs that that are uninteresting (e.g. link-local addresses).
// filters out changes to interface IPs that are uninteresting (e.g. link-local addresses).
func (cd *ChangeDelta) isInterestingInterfaceChange() bool {
// If there is no old state, everything is considered changed.
if cd.old == nil {
@@ -250,7 +306,12 @@ func (cd *ChangeDelta) isInterestingInterfaceChange() bool {
}
newIps = filterRoutableIPs(newIps)
if !oldInterface.Equal(newInterface) || !prefixesEqual(oldIps, newIps) {
// Only consider routable IP changes and up/down state transitions
// as interesting. Transient metadata changes (Flags like FlagRunning,
// MTU, etc.) should not trigger a major link change, as they cause
// false "major" events on macOS and Windows when the OS notifies us
// of interface changes that don't affect connectivity.
if oldInterface.IsUp() != newInterface.IsUp() || !prefixesEqual(oldIps, newIps) {
return true
}
}
@@ -282,8 +343,8 @@ func (cd *ChangeDelta) isInterestingInterfaceChange() bool {
}
oldIps = filterRoutableIPs(oldIps)
// The interface's IPs, Name, MTU, etc have changed. This is definitely interesting.
if !newInterface.Equal(oldInterface) || !prefixesEqual(oldIps, newIps) {
// Only consider routable IP changes and up/down state transitions.
if newInterface.IsUp() != oldInterface.IsUp() || !prefixesEqual(oldIps, newIps) {
return true
}
}
@@ -319,12 +380,10 @@ func New(bus *eventbus.Bus, logf logger.Logf) (*Monitor, error) {
lastWall: wallTime(),
}
m.changed = eventbus.Publish[ChangeDelta](m.b)
st, err := m.interfaceStateUncached()
if err != nil {
return nil, err
}
m.ifState = st
// Subscribe to OS change notifications before taking the initial
// snapshot, so a change between the two is queued rather than lost.
var err error
m.om, err = newOSMon(bus, logf, m)
if err != nil {
return nil, err
@@ -333,6 +392,13 @@ func New(bus *eventbus.Bus, logf logger.Logf) (*Monitor, error) {
return nil, errors.New("newOSMon returned nil, nil")
}
st, err := m.interfaceStateUncached()
if err != nil {
m.om.Close()
return nil, err
}
m.ifState = st
return m, nil
}
@@ -357,6 +423,12 @@ func (m *Monitor) InterfaceState() *State {
return m.ifState
}
// ProbeLocks acquires and releases the monitor's internal mutex.
func (m *Monitor) ProbeLocks() {
m.mu.Lock()
m.mu.Unlock()
}
func (m *Monitor) interfaceStateUncached() (*State, error) {
return getState(tsIfProps.tsIfName())
}
@@ -579,7 +651,8 @@ func (m *Monitor) handlePotentialChange(newState *State, forceCallbacks bool) {
return
}
delta, err := NewChangeDelta(oldState, newState, timeJumped, false)
jumpDuration := m.jumpDuration
delta, err := NewChangeDelta(oldState, newState, jumpDuration, false)
if err != nil {
m.logf("[unexpected] error creating ChangeDelta: %v", err)
return
@@ -592,12 +665,13 @@ func (m *Monitor) handlePotentialChange(newState *State, forceCallbacks bool) {
// See if we have a queued or new time jump signal.
if timeJumped {
m.resetTimeJumpedLocked()
m.logf("time jump detected (slept %v), probably wake from sleep", jumpDuration.Round(time.Second))
}
metricChange.Add(1)
if delta.RebindLikelyRequired {
metricChangeMajor.Add(1)
}
if delta.TimeJumped {
if delta.TimeJumped() {
metricChangeTimeJump.Add(1)
}
m.changed.Publish(*delta)
@@ -659,14 +733,14 @@ func (m *Monitor) checkWallTimeAdvanceLocked() bool {
panic("unreachable") // if callers are correct
}
now := wallTime()
if now.Sub(m.lastWall) > pollWallTimeInterval*3/2 {
m.timeJumped = true // it is reset by debounce.
if elapsed := now.Sub(m.lastWall); elapsed > pollWallTimeInterval*3/2 {
m.jumpDuration = elapsed
}
m.lastWall = now
return m.timeJumped
return m.jumpDuration != 0
}
// resetTimeJumpedLocked consumes the signal set by checkWallTimeAdvanceLocked.
func (m *Monitor) resetTimeJumpedLocked() {
m.timeJumped = false
m.jumpDuration = 0
}
+12 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package netmon
@@ -150,6 +150,17 @@ func (m *darwinRouteMon) skipInterfaceAddrMessage(msg *route.InterfaceAddrMessag
}
func (m *darwinRouteMon) skipRouteMessage(msg *route.RouteMessage) bool {
// RTM_MISS fires on every failed route lookup (no matching entry in the
// routing table). It scales with traffic volume, not network-state
// changes, and is never the leading signal for a topology change: route
// withdrawals emit RTM_DELETE synchronously before any subsequent lookup
// can miss. Letting these through causes netmon to report spurious
// link changes, which trigger a re-STUN/netcheck loop when a probe
// destination is unreachable (e.g. IPv6 DERP probes on a network with
// no IPv6 default route), as each failed probe emits another RTM_MISS.
if msg.Type == unix.RTM_MISS {
return true
}
if ip := ipOfAddr(addrType(msg.Addrs, unix.RTAX_DST)); ip.IsLinkLocalUnicast() {
// Skip those like:
// dst = fe80::b476:66ff:fe30:c8f6%15
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package netmon
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !android
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build (!linux && !freebsd && !windows && !darwin) || android
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package netmon
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !windows && !darwin
+108 -8
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package netmon
@@ -41,7 +41,12 @@ func isProblematicInterface(nif *net.Interface) bool {
// DoS each other by doing traffic amplification, both of them
// preferring/trying to use each other for transport. See:
// https://github.com/tailscale/tailscale/issues/1208
if strings.HasPrefix(name, "zt") || (runtime.GOOS == "windows" && strings.Contains(name, "ZeroTier")) {
// TODO(https://github.com/tailscale/tailscale/issues/18824): maybe exclude
// "WireGuard tunnel 0" as well on Windows (NetBird), but the name seems too
// generic where there is not a platform standard (on Linux wt0 is at least
// explicitly different from the WireGuard conventional default of wg0).
if strings.HasPrefix(name, "zt") || name == "wt0" /* NetBird */ ||
(runtime.GOOS == "windows" && strings.Contains(name, "ZeroTier")) {
return true
}
return false
@@ -413,7 +418,7 @@ func (s *State) HasIP(ip netip.Addr) bool {
}
for _, pv := range s.InterfaceIPs {
for _, p := range pv {
if p.Contains(ip) {
if p.Addr() == ip {
return true
}
}
@@ -438,6 +443,91 @@ func (a Interface) Equal(b Interface) bool {
return true
}
// InterfaceDiff returns a human-readable summary of the differences between s
// and s2 that would cause Equal to return false. It returns "" if the states
// are equal. This is useful for debugging false link change events where the
// State.String() output looks identical but Equal() returns false because it
// checks fields not shown in String() (like interface Flags, MTU, HardwareAddr).
func (s *State) InterfaceDiff(s2 *State) string {
if s == nil && s2 == nil {
return ""
}
if s == nil {
return "old=nil"
}
if s2 == nil {
return "new=nil"
}
var diffs []string
if s.HaveV6 != s2.HaveV6 {
diffs = append(diffs, fmt.Sprintf("HaveV6: %v->%v", s.HaveV6, s2.HaveV6))
}
if s.HaveV4 != s2.HaveV4 {
diffs = append(diffs, fmt.Sprintf("HaveV4: %v->%v", s.HaveV4, s2.HaveV4))
}
if s.IsExpensive != s2.IsExpensive {
diffs = append(diffs, fmt.Sprintf("IsExpensive: %v->%v", s.IsExpensive, s2.IsExpensive))
}
if s.DefaultRouteInterface != s2.DefaultRouteInterface {
diffs = append(diffs, fmt.Sprintf("DefaultRoute: %q->%q", s.DefaultRouteInterface, s2.DefaultRouteInterface))
}
if s.HTTPProxy != s2.HTTPProxy {
diffs = append(diffs, fmt.Sprintf("HTTPProxy: %q->%q", s.HTTPProxy, s2.HTTPProxy))
}
if s.PAC != s2.PAC {
diffs = append(diffs, fmt.Sprintf("PAC: %q->%q", s.PAC, s2.PAC))
}
if len(s.Interface) != len(s2.Interface) {
diffs = append(diffs, fmt.Sprintf("numInterfaces: %d->%d", len(s.Interface), len(s2.Interface)))
}
if len(s.InterfaceIPs) != len(s2.InterfaceIPs) {
diffs = append(diffs, fmt.Sprintf("numInterfaceIPs: %d->%d", len(s.InterfaceIPs), len(s2.InterfaceIPs)))
}
for iname, i := range s.Interface {
i2, ok := s2.Interface[iname]
if !ok {
diffs = append(diffs, fmt.Sprintf("if %s: removed", iname))
continue
}
if !i.Equal(i2) {
if i.Interface != nil && i2.Interface != nil {
if i.Flags != i2.Flags {
diffs = append(diffs, fmt.Sprintf("if %s flags: %v->%v", iname, i.Flags, i2.Flags))
}
if i.MTU != i2.MTU {
diffs = append(diffs, fmt.Sprintf("if %s MTU: %d->%d", iname, i.MTU, i2.MTU))
}
if i.Index != i2.Index {
diffs = append(diffs, fmt.Sprintf("if %s index: %d->%d", iname, i.Index, i2.Index))
}
if !bytes.Equal([]byte(i.HardwareAddr), []byte(i2.HardwareAddr)) {
diffs = append(diffs, fmt.Sprintf("if %s hwaddr: %v->%v", iname, i.HardwareAddr, i2.HardwareAddr))
}
}
if i.Desc != i2.Desc {
diffs = append(diffs, fmt.Sprintf("if %s desc: %q->%q", iname, i.Desc, i2.Desc))
}
}
}
for iname := range s2.Interface {
if _, ok := s.Interface[iname]; !ok {
diffs = append(diffs, fmt.Sprintf("if %s: added", iname))
}
}
for iname, vv := range s.InterfaceIPs {
vv2 := s2.InterfaceIPs[iname]
if !slices.Equal(vv, vv2) {
diffs = append(diffs, fmt.Sprintf("ips %s: %v->%v", iname, vv, vv2))
}
}
for iname := range s2.InterfaceIPs {
if _, ok := s.InterfaceIPs[iname]; !ok {
diffs = append(diffs, fmt.Sprintf("ips %s: added %v", iname, s2.InterfaceIPs[iname]))
}
}
return strings.Join(diffs, "; ")
}
func (s *State) HasPAC() bool { return s != nil && s.PAC != "" }
// AnyInterfaceUp reports whether any interface seems like it has Internet access.
@@ -736,6 +826,14 @@ func RegisterInterfaceGetter(getInterfaces func() ([]Interface, error)) {
altNetInterfaces = getInterfaces
}
// HookInterfacesFallback is a hook for the androidbin feature to
// provide a last-resort interface list when the OS denies
// net.Interfaces to the process, as Android does to app UIDs. It is
// consulted only when no getter was registered with
// RegisterInterfaceGetter and net.Interfaces failed; if it returns an
// error, the net.Interfaces error is returned instead.
var HookInterfacesFallback feature.Hook[func() ([]Interface, error)]
// InterfaceList is a list of interfaces on the machine.
type InterfaceList []Interface
@@ -755,6 +853,11 @@ func netInterfaces() ([]Interface, error) {
}
ifs, err := net.Interfaces()
if err != nil {
if f, ok := HookInterfacesFallback.GetOk(); ok {
if ret, err := f(); err == nil {
return ret, nil
}
}
return nil, err
}
ret := make([]Interface, len(ifs))
@@ -807,11 +910,8 @@ func (m *Monitor) HasCGNATInterface() (bool, error) {
if hasCGNATInterface || !i.IsUp() || isTailscaleInterface(i.Name, pfxs) {
return
}
for _, pfx := range pfxs {
if cgnatRange.Overlaps(pfx) {
hasCGNATInterface = true
break
}
if slices.ContainsFunc(pfxs, cgnatRange.Overlaps) {
hasCGNATInterface = true
}
})
if err != nil {
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package netns
+13 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package netns contains the common code for using the Go net package
@@ -46,6 +46,18 @@ func SetBindToInterfaceByRoute(logf logger.Logf, v bool) {
}
}
// When true, disableAndroidBindToActiveNetwork skips binding sockets to the currently
// active network on Android.
var disableAndroidBindToActiveNetwork atomic.Bool
// SetDisableAndroidBindToActiveNetwork disables the default behavior of binding
// sockets to the currently active network on Android.
func SetDisableAndroidBindToActiveNetwork(logf logger.Logf, v bool) {
if runtime.GOOS == "android" && disableAndroidBindToActiveNetwork.Swap(v) != v {
logf("netns: disableAndroidBindToActiveNetwork changed to %v", v)
}
}
var disableBindConnToInterface atomic.Bool
// SetDisableBindConnToInterface disables the (normal) behavior of binding
+37 -4
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build android
@@ -17,6 +17,9 @@ import (
var (
androidProtectFuncMu sync.Mutex
androidProtectFunc func(fd int) error
androidBindToNetworkFuncMu sync.Mutex
androidBindToNetworkFunc func(fd int) error
)
// UseSocketMark reports whether SO_MARK is in use. Android does not use SO_MARK.
@@ -50,6 +53,14 @@ func SetAndroidProtectFunc(f func(fd int) error) {
androidProtectFunc = f
}
// SetAndroidBindToNetworkFunc registers a func provided by Android that binds
// the socket FD to the currently selected underlying network.
func SetAndroidBindToNetworkFunc(f func(fd int) error) {
androidBindToNetworkFuncMu.Lock()
defer androidBindToNetworkFuncMu.Unlock()
androidBindToNetworkFunc = f
}
func control(logger.Logf, *netmon.Monitor) func(network, address string, c syscall.RawConn) error {
return controlC
}
@@ -60,14 +71,36 @@ func control(logger.Logf, *netmon.Monitor) func(network, address string, c sysca
// and net.ListenConfig.Control.
func controlC(network, address string, c syscall.RawConn) error {
var sockErr error
err := c.Control(func(fd uintptr) {
fdInt := int(fd)
// Protect from VPN loops
androidProtectFuncMu.Lock()
f := androidProtectFunc
pf := androidProtectFunc
androidProtectFuncMu.Unlock()
if f != nil {
sockErr = f(int(fd))
if pf != nil {
if err := pf(fdInt); err != nil {
sockErr = err
return
}
}
if disableAndroidBindToActiveNetwork.Load() {
return
}
androidBindToNetworkFuncMu.Lock()
bf := androidBindToNetworkFunc
androidBindToNetworkFuncMu.Unlock()
if bf != nil {
if err := bf(fdInt); err != nil {
sockErr = err
return
}
}
})
if err != nil {
return fmt.Errorf("RawConn.Control on %T: %w", c, err)
}
+2 -2
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build darwin
@@ -85,7 +85,7 @@ func getInterfaceIndex(logf logger.Logf, netMon *netmon.Monitor, address string)
// Preferentially choose the OS's view of the default if index. Due to the way darwin sets the delegated
// interface on tunnel creation only, it is possible for netmon to have a stale view of the default and
// netmon's view is often temporarily wrong during network transitions, or for us to not have the
// the the oses view of the defaultIf yet.
// the oses view of the defaultIf yet.
if osIferr == nil {
idx = osIf.InterfaceIndex
errOut = nil
+2 -2
View File
@@ -1,7 +1,7 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !linux && !windows && !darwin
//go:build !linux && !windows && !darwin && !openbsd
package netns
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build darwin || windows
+35 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux && !android
@@ -6,7 +6,9 @@
package netns
import (
"errors"
"fmt"
"log"
"net"
"os"
"sync"
@@ -131,3 +133,35 @@ func bindToDevice(fd uintptr) error {
}
return nil
}
// SetListenConfigInterfaceName sets lc.Control so that sockets created
// with it are bound to the named interface using SO_BINDTODEVICE, and
// thus only receive packets that arrived on that interface. It is the
// Linux counterpart of darwin's SetListenConfigInterfaceIndex.
//
// Failures to set the socket option are logged and otherwise ignored:
// the socket then simply listens as it would have without this call.
// Setting SO_BINDTODEVICE requires CAP_NET_RAW, and hardening must not
// take out the caller's listener on systems where that is unavailable.
func SetListenConfigInterfaceName(lc *net.ListenConfig, ifName string) error {
if lc == nil {
return errors.New("nil ListenConfig")
}
if lc.Control != nil {
return errors.New("ListenConfig.Control already set")
}
lc.Control = func(network, address string, c syscall.RawConn) error {
var sockErr error
err := c.Control(func(fd uintptr) {
sockErr = unix.SetsockoptString(int(fd), unix.SOL_SOCKET, unix.SO_BINDTODEVICE, ifName)
})
if sockErr != nil {
log.Printf("[unexpected] netns: SO_BINDTODEVICE(%q) on %v %v: %v", ifName, network, address, sockErr)
}
if err != nil {
return fmt.Errorf("RawConn.Control on %T: %w", c, err)
}
return nil
}
return nil
}
+178
View File
@@ -0,0 +1,178 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build openbsd
package netns
import (
"fmt"
"os/exec"
"strconv"
"strings"
"sync"
"syscall"
"golang.org/x/sys/unix"
"tailscale.com/net/netmon"
"tailscale.com/types/logger"
)
var (
bypassMu sync.Mutex
bypassRtable int
)
// Called by the router when exit node routes are configured.
func SetBypassRtable(rtable int) {
bypassMu.Lock()
defer bypassMu.Unlock()
bypassRtable = rtable
}
func GetBypassRtable() int {
bypassMu.Lock()
defer bypassMu.Unlock()
return bypassRtable
}
func control(logf logger.Logf, _ *netmon.Monitor) func(network, address string, c syscall.RawConn) error {
return func(network, address string, c syscall.RawConn) error {
return controlC(logf, network, address, c)
}
}
func controlC(logf logger.Logf, _, address string, c syscall.RawConn) error {
if isLocalhost(address) {
return nil
}
rtable := GetBypassRtable()
if rtable == 0 {
return nil
}
return bindToRtable(c, rtable, logf)
}
func bindToRtable(c syscall.RawConn, rtable int, logf logger.Logf) error {
var sockErr error
err := c.Control(func(fd uintptr) {
sockErr = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_RTABLE, rtable)
})
if sockErr != nil {
logf("netns: SO_RTABLE(%d): %v", rtable, sockErr)
}
if err != nil {
return fmt.Errorf("RawConn.Control: %w", err)
}
return sockErr
}
// SetupBypassRtable creates a bypass rtable with the existing default route
// in it routing through its existing physical interface. It should be called
// by the router when exit node routes are being added.
// Returns the rtable number.
func SetupBypassRtable(logf logger.Logf) (int, error) {
bypassMu.Lock()
defer bypassMu.Unlock()
if bypassRtable != 0 {
return bypassRtable, nil
}
gw, err := getPhysicalGateway()
if err != nil {
return 0, fmt.Errorf("getPhysicalGateway: %w", err)
}
rtable, err := findAvailableRtable()
if err != nil {
return 0, fmt.Errorf("findAvailableRtable: %w", err)
}
// Add the existing default route interface to the new bypass rtable
out, err := exec.Command("route", "-T", strconv.Itoa(rtable), "-qn", "add", "default", gw).CombinedOutput()
if err != nil {
return 0, fmt.Errorf("route -T%d add default %s: %w\n%s", rtable, gw, err, out)
}
bypassRtable = rtable
logf("netns: created bypass rtable %d with default route via %s", rtable, gw)
return rtable, nil
}
func CleanupBypassRtable(logf logger.Logf) {
bypassMu.Lock()
defer bypassMu.Unlock()
if bypassRtable == 0 {
return
}
// Delete the default route from the bypass rtable which should clear it
out, err := exec.Command("route", "-T", strconv.Itoa(bypassRtable), "-qn", "delete", "default").CombinedOutput()
if err != nil {
logf("netns: failed to clear bypass route: %v\n%s", err, out)
} else {
logf("netns: cleared bypass rtable %d", bypassRtable)
}
bypassRtable = 0
}
// getPhysicalGateway returns the default gateway IP that goes through a
// physical interface (not tun).
func getPhysicalGateway() (string, error) {
out, err := exec.Command("route", "-n", "show", "-inet").CombinedOutput()
if err != nil {
return "", fmt.Errorf("route show: %w", err)
}
// Parse the routing table looking for default routes not via tun
for _, line := range strings.Split(string(out), "\n") {
fields := strings.Fields(line)
if len(fields) < 8 {
continue
}
// Format: Destination Gateway Flags Refs Use Mtu Prio Iface
dest := fields[0]
gateway := fields[1]
iface := fields[7]
if dest == "default" && !strings.HasPrefix(iface, "tun") {
return gateway, nil
}
}
return "", fmt.Errorf("no physical default gateway found")
}
func findAvailableRtable() (int, error) {
for i := 1; i <= 255; i++ {
out, err := exec.Command("route", "-T", strconv.Itoa(i), "-n", "show", "-inet").CombinedOutput()
if err != nil {
// rtable doesn't exist, consider it available
return i, nil
}
// Check if the output only contains the header (no actual routes)
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
hasRoutes := false
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "Routing") || strings.HasPrefix(line, "Destination") {
continue
}
hasRoutes = true
break
}
if !hasRoutes {
return i, nil
}
}
return 0, fmt.Errorf("no available rtable")
}
func UseSocketMark() bool {
return false
}
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package netns
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ios && !js && !android && !ts_omit_useproxy
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package netutil
+65 -101
View File
@@ -1,16 +1,15 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package netutil
import (
"bytes"
"errors"
"fmt"
"io"
"net/netip"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
@@ -61,7 +60,12 @@ func protocolsRequiredForForwarding(routes []netip.Prefix, state *netmon.State)
func CheckIPForwarding(routes []netip.Prefix, state *netmon.State) (warn, err error) {
if runtime.GOOS != "linux" {
switch runtime.GOOS {
case "dragonfly", "freebsd", "netbsd", "openbsd":
case "freebsd":
if state == nil {
return nil, fmt.Errorf("Couldn't check system's IP forwarding configuration; no link state")
}
return checkIPForwardingFreeBSD(routes, state)
case "dragonfly", "netbsd", "openbsd":
return fmt.Errorf("Subnet routing and exit nodes only work with additional manual configuration on %v, and is not currently officially supported.", runtime.GOOS), nil
case "illumos", "solaris":
_, err := ipForwardingEnabledSunOS(ipv4, "")
@@ -146,64 +150,6 @@ func CheckIPForwarding(routes []netip.Prefix, state *netmon.State) (warn, err er
return nil, nil
}
// CheckReversePathFiltering reports whether reverse path filtering is either
// disabled or set to 'loose' mode for exit node functionality on any
// interface.
//
// The routes should only be advertised routes, and should not contain the
// node's Tailscale IPs.
//
// This function returns an error if it is unable to determine whether reverse
// path filtering is enabled, or a warning describing configuration issues if
// reverse path fitering is non-functional or partly functional.
func CheckReversePathFiltering(state *netmon.State) (warn []string, err error) {
if runtime.GOOS != "linux" {
return nil, nil
}
if state == nil {
return nil, errors.New("no link state")
}
// The kernel uses the maximum value for rp_filter between the 'all'
// setting and each per-interface config, so we need to fetch both.
allSetting, err := reversePathFilterValueLinux("all")
if err != nil {
return nil, fmt.Errorf("reading global rp_filter value: %w", err)
}
const (
filtOff = 0
filtStrict = 1
filtLoose = 2
)
// Because the kernel use the max rp_filter value, each interface will use 'loose', so we
// can abort early.
if allSetting == filtLoose {
return nil, nil
}
for _, iface := range state.Interface {
if iface.IsLoopback() {
continue
}
iSetting, err := reversePathFilterValueLinux(iface.Name)
if err != nil {
return nil, fmt.Errorf("reading interface rp_filter value for %q: %w", iface.Name, err)
}
// Perform the same max() that the kernel does
if allSetting > iSetting {
iSetting = allSetting
}
if iSetting == filtStrict {
warn = append(warn, fmt.Sprintf("interface %q has strict reverse-path filtering enabled", iface.Name))
}
}
return warn, nil
}
// ipForwardSysctlKey returns the sysctl key for the given protocol and iface.
// When the dotFormat parameter is true the output is formatted as `net.ipv4.ip_forward`,
// else it is `net/ipv4/ip_forward`
@@ -235,25 +181,6 @@ func ipForwardSysctlKey(format sysctlFormat, p protocol, iface string) string {
return fmt.Sprintf(k, iface)
}
// rpFilterSysctlKey returns the sysctl key for the given iface.
//
// Format controls whether the output is formatted as
// `net.ipv4.conf.iface.rp_filter` or `net/ipv4/conf/iface/rp_filter`.
func rpFilterSysctlKey(format sysctlFormat, iface string) string {
// No iface means all interfaces
if iface == "" {
iface = "all"
}
k := "net/ipv4/conf/%s/rp_filter"
if format == dotFormat {
// Swap the delimiters.
iface = strings.ReplaceAll(iface, ".", "/")
k = strings.ReplaceAll(k, "/", ".")
}
return fmt.Sprintf(k, iface)
}
type sysctlFormat int
const (
@@ -276,7 +203,10 @@ const (
// sysctl (which on Linux just reads from /proc/sys anyway).
func ipForwardingEnabledLinux(p protocol, iface string) (bool, error) {
k := ipForwardSysctlKey(slashFormat, p, iface)
bs, err := os.ReadFile(filepath.Join("/proc/sys", k))
// Open k under /proc/sys with os.OpenInRoot so a ".." or symlinked
// component in the interface name can't read outside /proc/sys. It's
// openat-based, so it's also TOCTOU-resistant. See https://go.dev/blog/osroot.
f, err := os.OpenInRoot("/proc/sys", k)
if err != nil {
if os.IsNotExist(err) {
// If IPv6 is disabled, sysctl keys like "net.ipv6.conf.all.forwarding" just don't
@@ -291,6 +221,11 @@ func ipForwardingEnabledLinux(p protocol, iface string) (bool, error) {
}
return false, err
}
defer f.Close()
bs, err := io.ReadAll(f)
if err != nil {
return false, err
}
val, err := strconv.ParseInt(string(bytes.TrimSpace(bs)), 10, 32)
if err != nil {
@@ -305,30 +240,59 @@ func ipForwardingEnabledLinux(p protocol, iface string) (bool, error) {
return on, nil
}
// reversePathFilterValueLinux reports the reverse path filter setting on Linux
// for the given interface.
// checkIPForwardingFreeBSD reports whether IP forwarding is enabled for the
// protocols required by routes.
//
// The iface param determines which interface to check against; the empty
// string means to check the global config.
//
// This function tries to look up the value directly from `/proc/sys`, and
// falls back to using the `sysctl` command on failure.
func reversePathFilterValueLinux(iface string) (int, error) {
k := rpFilterSysctlKey(slashFormat, iface)
bs, err := os.ReadFile(filepath.Join("/proc/sys", k))
if err != nil {
// Fall back to the sysctl command
k := rpFilterSysctlKey(dotFormat, iface)
bs, err = exec.Command("sysctl", "-n", k).Output()
// Unlike Linux, FreeBSD has no per-interface forwarding knob: net.inet.ip.forwarding
// and net.inet6.ip6.forwarding are global, so we only check those.
func checkIPForwardingFreeBSD(routes []netip.Prefix, state *netmon.State) (warn, err error) {
const kbLink = "\nSee https://tailscale.com/s/ip-forwarding"
wantV4, wantV6 := protocolsRequiredForForwarding(routes, state)
if !wantV4 && !wantV6 {
return nil, nil
}
var warnings []string
if wantV4 {
on, err := ipForwardingEnabledFreeBSD(ipv4)
if err != nil {
return -1, fmt.Errorf("couldn't check %s (%v)", k, err)
return nil, fmt.Errorf("Couldn't check system's IP forwarding configuration, subnet routing/exit nodes may not work: %w%s", err, kbLink)
}
if !on {
warnings = append(warnings, "IPv4 forwarding is disabled.")
}
}
v, err := strconv.Atoi(string(bytes.TrimSpace(bs)))
if err != nil {
return -1, fmt.Errorf("couldn't parse %s (%v)", k, err)
if wantV6 {
on, err := ipForwardingEnabledFreeBSD(ipv6)
if err != nil {
return nil, fmt.Errorf("Couldn't check system's IP forwarding configuration, subnet routing/exit nodes may not work: %w%s", err, kbLink)
}
if !on {
warnings = append(warnings, "IPv6 forwarding is disabled.")
}
}
return v, nil
if len(warnings) > 0 {
return fmt.Errorf("%s\nSubnet routes and exit nodes may not work correctly.%s", strings.Join(warnings, "\n"), kbLink), nil
}
return nil, nil
}
// ipForwardingEnabledFreeBSD reports whether IP forwarding is enabled globally
// for the given protocol.
func ipForwardingEnabledFreeBSD(p protocol) (bool, error) {
k := "net.inet.ip.forwarding"
if p == ipv6 {
k = "net.inet6.ip6.forwarding"
}
bs, err := exec.Command("sysctl", "-n", k).Output()
if err != nil {
return false, fmt.Errorf("couldn't check sysctl %s: %w", k, err)
}
val, err := strconv.ParseInt(string(bytes.TrimSpace(bs)), 10, 32)
if err != nil {
return false, fmt.Errorf("couldn't parse %s: %w", k, err)
}
return val == 1, nil
}
func ipForwardingEnabledSunOS(p protocol, iface string) (bool, error) {
Loaded 100 of 2146 files, more files were not shown because too many files have changed in this diff. Show more