wgengine,feature: add optional webrtc path to magicsock

Add an optional WebRTC connectivity path to magicsock, primarily so
browser (js/wasm) nodes, which cannot open raw UDP sockets, can reach
peers directly instead of always relaying through DERP.

The implementation lives in a new feature/webrtc package that plugs into
magicsock through a hook, so magicsock itself keeps no dependency on
pion/WebRTC. The feature is opt-in: it is linked in only when a program
blank-imports feature/webrtc, and its init registers the manager via
magicsock.HookNewWebRTCManager. When it is not linked, the hook stays
unset and the WebRTC path is absent.

Signaling reuses the existing disco/DERP path rather than a standalone
server: offers, answers, and ICE candidates travel as a single new
TypeWebRTCSignal disco message, so no external signaling server is
needed. Once a data channel is up it appears to the rest of magicsock as
a peer path at the WebRTCMagicIP (127.3.3.41) address, ranked above DERP
but below direct UDP. WebRTC is only attempted when at least one side is
a js node, so native-to-native peers are unaffected.

TS_DEBUG_NEVER_DIRECT_WEBRTC disables the path at runtime for debugging,
falling back to DERP.

Updates #21027

Signed-off-by: Adriano Sela Aviles <adriano@tailscale.com>
This commit is contained in:
Adriano Sela Aviles authored and Adriano Sela Aviles committed 2026-10-20 17:19:48 +00:00
1 parent 47743a8b9a
commit 98f7c4241e
27 files changed
+2238 -33

No files matched your search

+14 -1
View File
@@ -23,6 +23,7 @@ import (
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/net/netmon"
"tailscale.com/tailcfg"
"tailscale.com/util/dnsname"
)
@@ -196,7 +197,19 @@ func runStatus(ctx context.Context, args []string) error {
if relay != "" && ps.CurAddr == "" && ps.PeerRelay == "" {
f("relay %q", relay)
} else if ps.CurAddr != "" {
f("direct %s", ps.CurAddr)
// Check if this is a WebRTC connection (address matches WebRTC magic IP)
if strings.HasPrefix(ps.CurAddr, tailcfg.WebRTCMagicIP) {
// Extract the actual remote address from CurAddr, which for a WebRTC path
// is of the form "${WEBRTC_MAGIC_IP}:${DUMMY_PORT} (${REAL_IP_AND_PORT})"
// e.g. "127.3.3.41:12345 (134.209.53.229:37792)".
realRemoteAddr := "<UNKNOWN>"
if idx := strings.Index(ps.CurAddr, " ("); idx > 0 {
realRemoteAddr = ps.CurAddr[idx+2 : len(ps.CurAddr)-1]
}
f("webrtc %s", realRemoteAddr)
} else {
f("direct %s", ps.CurAddr)
}
} else if ps.PeerRelay != "" {
f("peer-relay %s", ps.PeerRelay)
}
+59
View File
@@ -20,6 +20,7 @@
package disco
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
@@ -51,6 +52,11 @@ const (
TypeCallMeMaybeVia = MessageType(0x07)
TypeAllocateUDPRelayEndpointRequest = MessageType(0x08)
TypeAllocateUDPRelayEndpointResponse = MessageType(0x09)
// TypeWebRTCSignal carries a WebRTC signaling message (offer, answer, or
// ICE candidate) between Tailscale peers via DERP, eliminating the need for
// an external signaling server. The specific kind and JSON-encoded WebRTC
// payload travel in the message body; see [WebRTCSignal].
TypeWebRTCSignal = MessageType(0x0A)
)
const v0 = byte(0)
@@ -103,6 +109,8 @@ func Parse(p []byte) (Message, error) {
return parseAllocateUDPRelayEndpointRequest(ver, p)
case TypeAllocateUDPRelayEndpointResponse:
return parseAllocateUDPRelayEndpointResponse(ver, p)
case TypeWebRTCSignal:
return parseWebRTCSignal(ver, p)
default:
return nil, fmt.Errorf("unknown message type 0x%02x", byte(t))
}
@@ -283,6 +291,55 @@ func parsePong(ver uint8, p []byte) (m *Pong, err error) {
return m, nil
}
// WebRTCSignalKind identifies which kind of WebRTC signaling message a
// [WebRTCSignal] carries. It is the first byte of the message body.
type WebRTCSignalKind uint8
const (
WebRTCOffer WebRTCSignalKind = 0 // Payload is a JSON webrtc.SessionDescription
WebRTCAnswer WebRTCSignalKind = 1 // Payload is a JSON webrtc.SessionDescription
WebRTCICECandidate WebRTCSignalKind = 2 // Payload is a JSON webrtc.ICECandidateInit
)
func (k WebRTCSignalKind) String() string {
switch k {
case WebRTCOffer:
return "offer"
case WebRTCAnswer:
return "answer"
case WebRTCICECandidate:
return "ice-candidate"
}
return "unknown"
}
// WebRTCSignal is sent only over DERP to deliver a WebRTC signaling message to
// a peer. Kind identifies whether it's an offer, answer, or ICE candidate;
// Payload is the corresponding JSON-encoded WebRTC value. A single message type
// (rather than one per kind) keeps the disco protocol and its dispatch small.
type WebRTCSignal struct {
Kind WebRTCSignalKind
Payload []byte
}
func (m *WebRTCSignal) AppendMarshal(b []byte) []byte {
// Body is one Kind byte followed by the raw JSON payload.
ret, p := appendMsgHeader(b, TypeWebRTCSignal, v0, 1+len(m.Payload))
p[0] = byte(m.Kind)
copy(p[1:], m.Payload)
return ret
}
func parseWebRTCSignal(_ uint8, p []byte) (*WebRTCSignal, error) {
if len(p) < 1 {
return nil, errShort
}
return &WebRTCSignal{
Kind: WebRTCSignalKind(p[0]),
Payload: bytes.Clone(p[1:]),
}, nil
}
// MessageSummary returns a short summary of m for logging purposes.
func MessageSummary(m Message) string {
switch m := m.(type) {
@@ -304,6 +361,8 @@ func MessageSummary(m Message) string {
return "allocate-udp-relay-endpoint-request"
case *AllocateUDPRelayEndpointResponse:
return "allocate-udp-relay-endpoint-response"
case *WebRTCSignal:
return "webrtc-" + m.Kind.String()
default:
return fmt.Sprintf("%#v", m)
}
+42
View File
@@ -0,0 +1,42 @@
//go:build js
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package webrtc
import (
"github.com/pion/webrtc/v4"
"tailscale.com/types/key"
"tailscale.com/wgengine/magicsock"
)
func newManagerBase(b magicsock.WebRTCBackend) *manager {
settingEngine := webrtc.SettingEngine{}
// Unlike native (base_native.go) we deliberately do NOT enable
// DetachDataChannels here. In the browser, pion's detached reader
// (datachannel_js_detach.go) is just a wrapper that installs
// dc.OnMessage(func(msg){ read <- msg }) on an unbuffered channel, so vs.
// calling OnMessage directly (as setupDataChannel does) it only adds a
// goroutine hop per message with no zero-copy win. We keep the manual
// OnMessage path.
//
// settingEngine.DetachDataChannels()
// Create API with setting engine
api := webrtc.NewAPI(
webrtc.WithSettingEngine(settingEngine),
)
return &manager{
logf: b.Logf,
b: b,
peerConnectionsByPeer: make(map[magicsock.WebRTCPeer]*peerState),
peerConnectionsByDisco: make(map[key.DiscoPublic]*peerState),
startConnectionCh: make(chan magicsock.WebRTCPeer, 256),
closeCh: make(chan struct{}),
runLoopStoppedCh: make(chan struct{}),
api: api,
}
}
+78
View File
@@ -0,0 +1,78 @@
//go:build !js
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package webrtc
import (
"time"
"github.com/pion/webrtc/v4"
"tailscale.com/types/key"
"tailscale.com/wgengine/magicsock"
)
func newManagerBase(b magicsock.WebRTCBackend) *manager {
settingEngine := webrtc.SettingEngine{}
// Enlarge the SCTP flow-control receive window so more unacknowledged bytes
// can be in flight; the default caps throughput on high-bandwidth links.
settingEngine.SetSCTPMaxReceiveBufferSize(64 << 20) // 64 MiB
// Keep the congestion window from collapsing to a tiny value, so throughput
// doesn't have to ramp up from near-zero after idle periods or loss.
settingEngine.SetSCTPMinCwnd(1 << 20) // 1 MiB
// Widen the fast-retransmit window and the congestion-avoidance growth step
// so the connection ramps up and recovers from loss faster.
settingEngine.SetSCTPFastRtxWnd(256 << 10) // 256 KiB
settingEngine.SetSCTPCwndCAStep(256 << 10) // 256 KiB
// Enlarge the DTLS replay-protection window. The default (64) causes
// legitimate packets to be dropped as duplicates when the sender gets ahead
// of the receiver by more than 64 packets, which happens easily at Gbps speeds.
settingEngine.SetDTLSReplayProtectionWindow(8192)
// Lower the SCTP retransmission timeout ceiling. The default (1s+) stalls
// congestion control for a full second after any loss event, which is
// catastrophic for throughput on a low-latency P2P link.
settingEngine.SetSCTPRTOMax(300 * time.Millisecond)
// DetachDataChannels lets us call dc.Detach() to get a raw io.ReadWriteCloser
// instead of using OnMessage callbacks. The callback path allocates a new
// DataChannelMessage struct and fires a goroutine wakeup per packet. The
// detached path lets us Read() directly into pre-allocated buffers in a
// tight goroutine loop, matching how the UDP receive path works.
settingEngine.DetachDataChannels()
// Make detached-channel writes block (applying backpressure) when the SCTP
// send buffer is full, instead of erroring and forcing the caller to spin.
// Only takes effect with DetachDataChannels.
settingEngine.EnableDataChannelBlockWrite(true)
// SCTP includes a CRC32c checksum on every chunk. DTLS already provides
// both integrity and authenticity for all data, so the SCTP checksum is
// redundant CPU work. Zero-checksum mode (RFC 9260) removes it.
settingEngine.EnableSCTPZeroChecksum(true)
// Create MediaEngine (required even though we only use DataChannel)
mediaEngine := &webrtc.MediaEngine{}
// Create API with setting engine
api := webrtc.NewAPI(
webrtc.WithSettingEngine(settingEngine),
webrtc.WithMediaEngine(mediaEngine),
)
return &manager{
logf: b.Logf,
b: b,
peerConnectionsByPeer: make(map[magicsock.WebRTCPeer]*peerState),
peerConnectionsByDisco: make(map[key.DiscoPublic]*peerState),
startConnectionCh: make(chan magicsock.WebRTCPeer, 256),
closeCh: make(chan struct{}),
runLoopStoppedCh: make(chan struct{}),
api: api,
}
}
+346
View File
@@ -0,0 +1,346 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package webrtc
import (
"net"
"runtime"
"strconv"
"github.com/pion/webrtc/v4"
"tailscale.com/types/key"
"tailscale.com/wgengine/magicsock"
)
// iceConfig is the pion configuration used for every peer connection.
var iceConfig = webrtc.Configuration{
ICEServers: []webrtc.ICEServer{
// We deliberately do not configure any TURN relays; DERP is our relay fallback.
// FIXME(adrianosela): run our own STUN? can we plug this into DERP's flavor of STUN?
{URLs: []string{"stun:stun.l.google.com:19302"}},
},
ICETransportPolicy: webrtc.ICETransportPolicyAll,
}
// startConnection initiates a WebRTC connection to a peer.
func (m *manager) startConnection(p magicsock.WebRTCPeer) {
if m.b.DisableWebRTC() {
return
}
// WebRTC is only worthwhile when at least one side is a browser/Wasm ("js")
// node, which cannot use direct UDP. Between two native peers, direct UDP
// or DERP already covers connectivity, so skip the WebRTC machinery
// entirely.
if runtime.GOOS != "js" && !p.IsJS() {
return
}
select {
case m.startConnectionCh <- p:
case <-m.closeCh:
default:
m.logf("webrtc: startConnection queue full for %v", p.NodeAddr())
}
}
// EnsureConnecting triggers a WebRTC connection to p if one is not already in
// progress or established. It also retries connections in terminal states
// (Failed, Closed). It is safe to call from the hot send path.
func (m *manager) EnsureConnecting(p magicsock.WebRTCPeer) {
m.mu.RLock()
ps, exists := m.peerConnectionsByPeer[p]
needConnect := !exists || ps.state == stateFailed || ps.state == stateClosed
m.mu.RUnlock()
if needConnect {
m.startConnection(p)
}
}
// retryFailedConnections re-queues any connections in a terminal state so they
// get a fresh attempt. This covers cases where both peers restart simultaneously
// and the initial attempt fails before DERP is established.
func (m *manager) retryFailedConnections() {
m.mu.RLock()
var toRetry []magicsock.WebRTCPeer
for p, ps := range m.peerConnectionsByPeer {
if ps.state == stateFailed || ps.state == stateClosed {
toRetry = append(toRetry, p)
}
}
m.mu.RUnlock()
for _, p := range toRetry {
m.logf("webrtc: retrying failed connection to peer %v", p.NodeAddr())
m.startConnection(p)
}
}
// handleStartConnection creates a new WebRTC connection to a peer.
func (m *manager) handleStartConnection(p magicsock.WebRTCPeer) {
// Query the endpoint before taking m.mu: DiscoKey/DERPReady/NodeAddr acquire
// the endpoint lock, and populatePeerStatus acquires the endpoint lock and
// then m.mu (via GetRemoteAddr), so holding m.mu across these would invert
// the lock order and can deadlock.
remoteDisco, ok := p.DiscoKey()
if !ok {
m.logf("webrtc: cannot start connection, peer has no disco key")
return
}
// If the peer's DERP address isn't known yet, the signaling offer will fail
// immediately. This can happen on startup or after a disco-key rotation
// before the DERP connection to the new key is established. The next netmap
// update will re-trigger startConnection once the peer is reachable.
if !p.DERPReady() {
return
}
m.mu.Lock()
// Check if we already have a connection.
if ps, exists := m.peerConnectionsByPeer[p]; exists {
switch ps.state {
case stateConnecting, stateConnected:
m.mu.Unlock()
return
default:
// Terminal state (Failed, Closed): close the old connection and
// remove it from the maps so we can create a fresh one below.
ps.peerConn.Close()
delete(m.peerConnectionsByPeer, p)
delete(m.peerConnectionsByDisco, ps.remoteDisco)
}
}
m.mu.Unlock()
m.logf("webrtc: starting connection to peer %v (disco %v)", p.NodeAddr(), remoteDisco.ShortString())
peerConn, err := m.api.NewPeerConnection(iceConfig)
if err != nil {
m.logf("webrtc: failed to create peer connection: %v", err)
return
}
ps := &peerState{
peer: p,
peerConn: peerConn,
remoteDisco: remoteDisco,
remoteNodeKey: p.NodeKey(),
state: stateConnecting,
}
// Publish the peer state. Re-check under the lock: NewPeerConnection was slow
// and released m.mu, so the manager may have closed (nil maps) or a
// concurrent incoming offer may have created an answerer conn for this peer.
// In either case discard this attempt rather than leak or clobber it.
m.mu.Lock()
if m.peerConnectionsByPeer == nil {
m.mu.Unlock()
peerConn.Close()
return
}
if _, exists := m.peerConnectionsByPeer[p]; exists {
m.mu.Unlock()
peerConn.Close()
return
}
m.peerConnectionsByPeer[p] = ps
m.peerConnectionsByDisco[remoteDisco] = ps
m.mu.Unlock()
m.setupPeerConnHandlers(ps)
// Create an unordered, unreliable data channel (MaxRetransmits=0).
// WireGuard is designed to run over raw UDP, which is unordered and
// unreliable. Using an ordered/reliable DataChannel (the default) wraps
// WireGuard in SCTP's reliable-ordered-stream semantics, causing
// head-of-line blocking whenever a packet is lost: SCTP holds back all
// subsequent packets until the missing one is retransmitted and delivered
// in order. That is why throughput over WebRTC was worse than DERP.
// Setting Ordered=false and MaxRetransmits=0 makes the DataChannel behave
// like a UDP socket, which is exactly what WireGuard expects.
ordered := false
maxRetransmits := uint16(0)
dataChannel, err := peerConn.CreateDataChannel("tailscale-wg", &webrtc.DataChannelInit{
Ordered: &ordered,
MaxRetransmits: &maxRetransmits,
})
if err != nil {
m.logf("webrtc: failed to create data channel: %v", err)
peerConn.Close()
return
}
ps.dataChannel.Store(dataChannel)
m.setupDataChannel(ps, dataChannel, remoteDisco, p)
// Create and send the offer on a separate goroutine, NOT on the runLoop.
// CreateOffer/SetLocalDescription are blocking calls into the browser's
// async RTCPeerConnection on wasm. During glare (both peers offer at once),
// an incoming offer handled on another goroutine can Close() this peerConn
// mid-call; doing that work on the runLoop would wedge it permanently
// (no more retries, no new connections). ps and ps.peerConn are already
// published to the maps above, so the glare handler can see and tear down
// this attempt safely while this goroutine unblocks or errors out on its
// own. sendOffer runs after SetLocalDescription, so a returning answer
// always finds our local description set.
go m.createAndSendOffer(ps, peerConn, remoteDisco, p)
}
// createAndSendOffer performs the blocking offer setup off the runLoop. See
// handleStartConnection for why this must not run on the runLoop.
func (m *manager) createAndSendOffer(ps *peerState, peerConn *webrtc.PeerConnection, remoteDisco key.DiscoPublic, p magicsock.WebRTCPeer) {
offer, err := peerConn.CreateOffer(nil)
if err != nil {
m.logf("webrtc: failed to create offer: %v", err)
peerConn.Close()
m.removeIfCurrent(p, remoteDisco, ps)
return
}
if err := peerConn.SetLocalDescription(offer); err != nil {
m.logf("webrtc: failed to set local description: %v", err)
peerConn.Close()
m.removeIfCurrent(p, remoteDisco, ps)
return
}
if err := m.sendOffer(remoteDisco, &offer); err != nil {
m.logf("webrtc: failed to send offer: %v", err)
peerConn.Close()
m.removeIfCurrent(p, remoteDisco, ps)
return
}
m.logf("webrtc: sent offer to peer %v", remoteDisco.ShortString())
}
// removeIfCurrent deletes ps from the peer maps if it is still the current
// entry for p, so a concurrent rebuild that already replaced it is not
// clobbered. Safe to call after the manager is closed (maps are nil).
func (m *manager) removeIfCurrent(p magicsock.WebRTCPeer, remoteDisco key.DiscoPublic, ps *peerState) {
m.mu.Lock()
defer m.mu.Unlock()
if cur, ok := m.peerConnectionsByPeer[p]; ok && cur == ps {
delete(m.peerConnectionsByPeer, p)
delete(m.peerConnectionsByDisco, remoteDisco)
}
}
// setupPeerConnHandlers wires the connection-state and ICE-candidate callbacks
// shared by the offerer and answerer paths.
func (m *manager) setupPeerConnHandlers(ps *peerState) {
ps.peerConn.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
m.handleConnectionStateChange(ps, state)
})
ps.peerConn.OnICECandidate(func(candidate *webrtc.ICECandidate) {
if candidate != nil {
m.handleLocalICECandidate(ps, candidate)
}
})
}
// setupDataChannel wires the handlers for a DataChannel (the offerer's
// self-created channel or the answerer's received channel). When it opens, it
// detaches for the zero-allocation reader path (falling back to OnMessage), then
// marks the WebRTC path ready so magicsock can probe and select it.
func (m *manager) setupDataChannel(ps *peerState, dc *webrtc.DataChannel, remoteDisco key.DiscoPublic, p magicsock.WebRTCPeer) {
dc.OnError(func(err error) {
m.logf("webrtc: data channel error for peer %v: %v", remoteDisco.ShortString(), err)
})
dc.OnOpen(func() {
// DetachDataChannels is enabled, so get a raw io.ReadWriteCloser and
// spin a dedicated reader goroutine (zero per-message allocations). If
// detaching isn't available, fall back to OnMessage callbacks.
if rwc, err := dc.Detach(); err == nil {
ps.dcRW.Store(&dataChannelRW{rwc})
go m.runDataChannelReader(ps, rwc)
} else {
m.logf("webrtc: data channel detach failed for peer %v, using OnMessage: %v",
remoteDisco.ShortString(), err)
dc.OnMessage(func(msg webrtc.DataChannelMessage) {
m.deliverMsg(ps, msg.Data)
})
}
m.logf("webrtc: data channel opened for peer %v", remoteDisco.ShortString())
if m.b.DisableWebRTC() {
return
}
m.setChannelReady(ps, p, true)
m.logf("webrtc: marked WebRTC channel ready for peer %v", remoteDisco.ShortString())
})
}
// handleConnectionStateChange handles WebRTC connection state changes.
func (m *manager) handleConnectionStateChange(ps *peerState, state webrtc.PeerConnectionState) {
m.logf("webrtc: connection state changed to %s for peer %v", state.String(), ps.remoteDisco.ShortString())
m.mu.Lock()
var channelDown bool
switch state {
case webrtc.PeerConnectionStateConnected:
ps.state = stateConnected
// Record and log the selected ICE candidate pair. This is the path ICE
// actually chose (LAN host vs. STUN server-reflexive vs. relay), and is
// the authoritative source for the remote address we report in status.
go func() {
cp, err := ps.peerConn.SCTP().Transport().ICETransport().GetSelectedCandidatePair()
if err != nil || cp == nil {
m.logf("webrtc: peer %v connected (selected candidate pair unavailable: %v)",
ps.remoteDisco.ShortString(), err)
return
}
m.logf("webrtc: peer %v connected via %s:%d → %s:%d (local %s, remote %s)",
ps.remoteDisco.ShortString(),
cp.Local.Address, cp.Local.Port,
cp.Remote.Address, cp.Remote.Port,
cp.Local.Typ, cp.Remote.Typ)
// Report the selected remote candidate as the peer's WebRTC address.
// This is a display string: cp.Remote.Address may be a numeric IP or
// an mDNS ".local" name (browsers hide LAN IPs behind these), and we
// surface either as-is.
m.mu.Lock()
ps.remoteAddr = net.JoinHostPort(cp.Remote.Address, strconv.Itoa(int(cp.Remote.Port)))
m.mu.Unlock()
}()
case webrtc.PeerConnectionStateFailed:
ps.state = stateFailed
ps.dcRW.Store(nil)
channelDown = true
case webrtc.PeerConnectionStateClosed:
ps.state = stateClosed
ps.dcRW.Store(nil)
channelDown = true
case webrtc.PeerConnectionStateDisconnected:
// Recoverable in principle; nothing to do. WebRTC liveness is driven by
// disco heartbeat pongs, so when traffic stops flowing the trust window
// lapses (~6.5s) and magicsock falls back to DERP. bestAddr is left to
// the disco machinery rather than demoted on this pion state edge, which
// keeps the two sides in agreement.
}
m.mu.Unlock()
if channelDown {
m.setChannelReady(ps, ps.peer, false)
}
}
// setChannelReady updates the peer's WebRTC channel-ready flag, but only if ps
// is still the current peerState for p. Pion fires open/close callbacks for
// different peerStates on unordered goroutines; without this a stale
// connection's close (ready=false) could clobber a newer connection's open
// (ready=true) and strand a live channel on DERP. It takes the endpoint lock
// via SetWebRTCChannelReady, so it must be called without m.mu held.
func (m *manager) setChannelReady(ps *peerState, p magicsock.WebRTCPeer, ready bool) {
m.mu.RLock()
cur, ok := m.peerConnectionsByPeer[p]
current := ok && cur == ps
m.mu.RUnlock()
if !current {
return
}
p.SetWebRTCChannelReady(ready)
}
+107
View File
@@ -0,0 +1,107 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package webrtc
import (
"encoding/binary"
"errors"
"fmt"
"io"
"net"
"github.com/pion/webrtc/v4"
"tailscale.com/types/key"
"tailscale.com/wgengine/magicsock"
)
// SendPacket sends a packet over a WebRTC data channel.
// The hot path is lock-free: we take a read-lock (not write-lock) to look up
// the peer state, then do an atomic load for the detached channel. Multiple
// concurrent senders for different peers never contend.
func (m *manager) SendPacket(dst key.DiscoPublic, b []byte) error {
m.mu.RLock()
ps, ok := m.peerConnectionsByDisco[dst]
m.mu.RUnlock()
if !ok {
return errors.New("no WebRTC connection")
}
// Native path: DetachDataChannels was enabled; use the raw io.ReadWriteCloser.
if rw := ps.dcRW.Load(); rw != nil {
if _, err := rw.Write(b); err != nil {
return fmt.Errorf("send failed: %w", err)
}
return nil
}
// JS/fallback path: use DataChannel.Send() directly.
dc := ps.dataChannel.Load()
if dc == nil || dc.ReadyState() != webrtc.DataChannelStateOpen {
return errors.New("data channel not ready")
}
return dc.Send(b)
}
// GetRemoteAddr returns the selected remote address (host:port) for a connected
// WebRTC peer, or "" if there is no connected peer for the disco key. The host
// may be a numeric IP or an mDNS ".local" name.
func (m *manager) GetRemoteAddr(dst key.DiscoPublic) string {
m.mu.RLock()
defer m.mu.RUnlock()
if ps, ok := m.peerConnectionsByDisco[dst]; ok && ps.state == stateConnected {
return ps.remoteAddr
}
return ""
}
// deliverMsg delivers one DataChannel message to the receive pipeline.
// It handles both single packets and batches (WebRTCBatchMagic framing) so the logic
// is shared between the native detached-reader path and the JS/fallback
// OnMessage callback path.
func (m *manager) deliverMsg(ps *peerState, data []byte) {
if len(data) == 0 {
return
}
// Batch: [magic][2-byte BE len][pkt]...[2-byte BE len][pkt]
if data[0] == magicsock.WebRTCBatchMagic {
data = data[1:]
for len(data) >= 2 {
pktLen := int(binary.BigEndian.Uint16(data))
data = data[2:]
if pktLen > len(data) {
m.logf("webrtc: batch framing error for peer %v: pktLen %d > remaining %d",
ps.remoteDisco.ShortString(), pktLen, len(data))
return
}
m.b.DeliverPacket(data[:pktLen], ps.remoteNodeKey)
data = data[pktLen:]
}
return
}
m.b.DeliverPacket(data, ps.remoteNodeKey)
}
// runDataChannelReader is the per-peer receive loop used when DetachDataChannels
// is enabled (native builds). It reads directly from the detached io.ReadWriteCloser
// into a reused buffer, avoiding the per-message goroutine wakeup and allocation
// that the OnMessage callback path incurs.
func (m *manager) runDataChannelReader(ps *peerState, rwc io.ReadWriteCloser) {
// Size the buffer to hold the largest possible batch.
// 64 WireGuard packets × ~1420 bytes + framing < 100 KiB; 256 KiB is safe.
buf := make([]byte, 256*1024)
for {
n, err := rwc.Read(buf)
if err != nil {
if !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrClosedPipe) && !errors.Is(err, net.ErrClosed) {
m.logf("webrtc: data channel read error for peer %v: %v", ps.remoteDisco.ShortString(), err)
}
ps.dcRW.Store(nil)
return
}
if n > 0 {
m.deliverMsg(ps, buf[:n])
}
}
}
+120
View File
@@ -0,0 +1,120 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package webrtc
import (
"encoding/binary"
"reflect"
"testing"
"tailscale.com/disco"
"tailscale.com/types/key"
"tailscale.com/wgengine/magicsock"
)
// fakeBackend is a test magicsock.WebRTCBackend that records delivered packets.
type fakeBackend struct {
delivered [][]byte
}
func (f *fakeBackend) LocalDiscoKey() key.DiscoPublic { return key.DiscoPublic{} }
func (f *fakeBackend) PeerForDisco(key.DiscoPublic) (magicsock.WebRTCPeer, bool) { return nil, false }
func (f *fakeBackend) SendSignal(key.DiscoPublic, disco.Message) error { return nil }
func (f *fakeBackend) DisableWebRTC() bool { return false }
func (f *fakeBackend) Logf(format string, args ...any) {}
func (f *fakeBackend) DeliverPacket(b []byte, _ key.NodePublic) {
// Copy: deliverMsg hands us sub-slices of a reused buffer.
f.delivered = append(f.delivered, append([]byte(nil), b...))
}
// encodeBatch builds the multi-packet wire framing that magicsock's
// sendWebRTCBatch produces: [magicsock.WebRTCBatchMagic][2-byte BE len][pkt]...
func encodeBatch(pkts ...[]byte) []byte {
out := []byte{magicsock.WebRTCBatchMagic}
for _, p := range pkts {
out = binary.BigEndian.AppendUint16(out, uint16(len(p)))
out = append(out, p...)
}
return out
}
func TestDeliverMsg(t *testing.T) {
pktA := []byte("alpha")
pktB := []byte("bravo")
tests := []struct {
name string
in []byte
want [][]byte
}{
{
name: "empty",
in: nil,
want: nil,
},
{
name: "single_unframed_packet",
in: pktA,
want: [][]byte{pktA},
},
{
name: "single_element_batch",
in: encodeBatch(pktA),
want: [][]byte{pktA},
},
{
name: "multi_element_batch",
in: encodeBatch(pktA, pktB),
want: [][]byte{pktA, pktB},
},
{
name: "truncated_length_header_ignored",
in: append([]byte{magicsock.WebRTCBatchMagic}, 0x00), // 1 dangling byte after magic
want: nil,
},
{
name: "declared_length_exceeds_remaining_dropped",
in: append([]byte{magicsock.WebRTCBatchMagic, 0x00, 0xFF}, pktA...), // says 255 bytes, has 5
want: nil,
},
{
name: "unframed_packet_starting_0x01",
in: []byte{0x01, 0x02, 0x03},
want: [][]byte{{0x01, 0x02, 0x03}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fb := &fakeBackend{}
m := &manager{b: fb, logf: func(string, ...any) {}}
ps := &peerState{}
m.deliverMsg(ps, tt.in)
// Normalize nil vs empty for comparison.
got := fb.delivered
if len(got) == 0 && len(tt.want) == 0 {
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("deliverMsg(%v) delivered %v, want %v", tt.in, got, tt.want)
}
})
}
}
// The zero-length-packet case above: a batch entry with len 0 advances the
// cursor but DeliverPacket is called with an empty slice. Verify that's what
// happens (it is delivered, just empty), so the count is right.
func TestDeliverMsgZeroLenEntryIsDelivered(t *testing.T) {
fb := &fakeBackend{}
m := &manager{b: fb, logf: func(string, ...any) {}}
m.deliverMsg(&peerState{}, encodeBatch([]byte("x"), nil, []byte("y")))
if len(fb.delivered) != 3 {
t.Fatalf("got %d delivered packets, want 3 (including the empty one)", len(fb.delivered))
}
if len(fb.delivered[1]) != 0 {
t.Errorf("middle packet = %q, want empty", fb.delivered[1])
}
}
+66
View File
@@ -0,0 +1,66 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package webrtc
import (
"io"
"sync/atomic"
"github.com/pion/webrtc/v4"
"tailscale.com/types/key"
"tailscale.com/wgengine/magicsock"
)
// connState represents the state of a WebRTC connection.
type connState int
const (
stateIdle connState = iota
stateConnecting
stateConnected
stateFailed
stateClosed
)
func (s connState) String() string {
switch s {
case stateIdle:
return "idle"
case stateConnecting:
return "connecting"
case stateConnected:
return "connected"
case stateFailed:
return "failed"
case stateClosed:
return "closed"
}
return "unknown"
}
// dataChannelRW is the detached io.ReadWriteCloser for a WebRTC DataChannel.
// It is stored via atomic.Pointer so the hot send path can retrieve it without
// holding the manager mutex.
type dataChannelRW struct {
io.ReadWriteCloser
}
// peerState tracks WebRTC connection state for a single peer.
type peerState struct {
peer magicsock.WebRTCPeer
peerConn *webrtc.PeerConnection
dataChannel atomic.Pointer[webrtc.DataChannel]
dcRW atomic.Pointer[dataChannelRW] // non-nil once the DataChannel is open
remoteDisco key.DiscoPublic
remoteNodeKey key.NodePublic // peer's node public key (for WireGuard)
remoteAddr string // selected remote ICE candidate "host:port"; may be an mDNS ".local" name
state connState
// remoteDescSet is true once SetRemoteDescription has been called.
// ICE candidates that arrive before that point are held in
// pendingCandidates and applied immediately after. Both fields are
// protected by manager.mu.
remoteDescSet bool
pendingCandidates []webrtc.ICECandidateInit
}
+17
View File
@@ -0,0 +1,17 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package webrtc
import (
"tailscale.com/feature"
"tailscale.com/wgengine/magicsock"
)
// featureName is the name of the feature implemented by this package.
const featureName = "webrtc"
func init() {
feature.Register(featureName)
magicsock.HookNewWebRTCManager.Set(newManager)
}
+44
View File
@@ -0,0 +1,44 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package webrtc
// This file implements the send side of WebRTC signaling: offers, answers, and
// ICE candidates are JSON-encoded and sent to the peer as disco messages over
// DERP (via the magicsock backend). Incoming messages arrive separately,
// through magicsock's disco dispatch into [manager.HandleSignal].
import (
"encoding/json"
"fmt"
"github.com/pion/webrtc/v4"
"tailscale.com/disco"
"tailscale.com/types/key"
)
// sendOffer sends an SDP offer to the peer with the given disco key.
func (m *manager) sendOffer(to key.DiscoPublic, offer *webrtc.SessionDescription) error {
return sendSignal(m, disco.WebRTCOffer, to, offer)
}
// sendAnswer sends an SDP answer to the peer with the given disco key.
func (m *manager) sendAnswer(to key.DiscoPublic, answer *webrtc.SessionDescription) error {
return sendSignal(m, disco.WebRTCAnswer, to, answer)
}
// sendCandidate sends an ICE candidate to the peer with the given disco key.
func (m *manager) sendCandidate(to key.DiscoPublic, candidate *webrtc.ICECandidateInit) error {
return sendSignal(m, disco.WebRTCICECandidate, to, candidate)
}
// sendSignal JSON-encodes msg and sends it to the peer as a [disco.WebRTCSignal]
// of the given kind. It is the shared body of the three send methods above,
// which differ only in msg's type and the kind.
func sendSignal[T any](m *manager, kind disco.WebRTCSignalKind, to key.DiscoPublic, msg *T) error {
payload, err := json.Marshal(msg)
if err != nil {
return fmt.Errorf("webrtc: marshal %s: %w", kind, err)
}
return m.b.SendSignal(to, &disco.WebRTCSignal{Kind: kind, Payload: payload})
}
+304
View File
@@ -0,0 +1,304 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package webrtc
import (
"encoding/json"
"github.com/pion/webrtc/v4"
"tailscale.com/disco"
"tailscale.com/types/key"
)
// maxPendingCandidates bounds how many remote ICE candidates we buffer before
// the remote description is set, so a peer that never answers cannot make us
// buffer without bound. ICE gathering for a single DataChannel yields well
// under this many candidates.
const maxPendingCandidates = 32
// dispatchPayload unmarshals a JSON-encoded signaling message received over
// disco and invokes dispatch with the sender's disco key. It is the shared body
// of the three kinds handled by [manager.HandleSignal].
func (m *manager) dispatchPayload[T any](kind disco.WebRTCSignalKind, fromDisco key.DiscoPublic, payload []byte, dispatch func(key.DiscoPublic, *T)) {
var msg T
if err := json.Unmarshal(payload, &msg); err != nil {
m.logf("webrtc: disco: failed to unmarshal %s from %v: %v", kind, fromDisco.ShortString(), err)
return
}
dispatch(fromDisco, &msg)
}
// HandleSignal implements magicsock.WebRTCManager. It unmarshals a WebRTC
// signaling message received over disco and dispatches it to the appropriate
// handler based on its kind.
func (m *manager) HandleSignal(fromDisco key.DiscoPublic, kind disco.WebRTCSignalKind, payload []byte) {
switch kind {
case disco.WebRTCOffer:
m.dispatchPayload(kind, fromDisco, payload, m.handleRemoteOffer)
case disco.WebRTCAnswer:
m.dispatchPayload(kind, fromDisco, payload, m.handleRemoteAnswer)
case disco.WebRTCICECandidate:
m.dispatchPayload(kind, fromDisco, payload, m.handleRemoteCandidate)
default:
m.logf("webrtc: disco: ignoring unknown signal kind %d from %v", kind, fromDisco.ShortString())
}
}
// handleRemoteOffer processes an incoming offer from a peer.
func (m *manager) handleRemoteOffer(remoteDisco key.DiscoPublic, offer *webrtc.SessionDescription) {
// For incoming connections, we need to find the endpoint by disco key.
// Snapshot ps.state under the lock; it's written by pion callbacks under
// m.mu, so it must not be read off-lock below.
m.mu.Lock()
ps, exists := m.peerConnectionsByDisco[remoteDisco]
var state connState
if exists {
state = ps.state
}
m.mu.Unlock()
if exists {
switch ps.peerConn.SignalingState() {
case webrtc.SignalingStateHaveLocalOffer:
// Glare: both sides sent offers simultaneously. Tiebreak by disco key:
// the peer with the lexicographically smaller local key wins and keeps
// its offer; the loser rolls back and answers the remote offer instead.
localDisco := m.b.LocalDiscoKey()
if localDisco.Compare(remoteDisco) < 0 {
// We win, so ignore their offer; they will roll back and answer ours.
m.logf("webrtc: glare with peer %v: ignoring their offer (we win tiebreak)", remoteDisco.ShortString())
return
}
// We lose, so roll back our offer and fall through to answer theirs.
m.logf("webrtc: glare with peer %v: rolling back our offer (we lose tiebreak)", remoteDisco.ShortString())
if err := ps.peerConn.SetLocalDescription(webrtc.SessionDescription{Type: webrtc.SDPTypeRollback}); err != nil {
m.logf("webrtc: glare rollback failed: %v; closing and recreating", err)
ps.peerConn.Close()
m.mu.Lock()
delete(m.peerConnectionsByPeer, ps.peer)
delete(m.peerConnectionsByDisco, remoteDisco)
m.mu.Unlock()
exists = false
}
case webrtc.SignalingStateStable:
// A peer sending a fresh offer while we're in a stable signaling
// state means it wants to (re)build the connection: it has lost or is
// replacing its side. Our own ps.state may still say "connected"
// because a stale/dead ICE connection hasn't surfaced as Failed yet,
// which is exactly how we can get wedged (we ignore the peer's
// recovery offer while black-holing traffic on a dead path). So the
// peer's re-offer always wins: tear down our entry and answer fresh
// below. Closing the peer connection fires a Closed state callback
// that marks the WebRTC channel not-ready, so the heartbeat stops
// probing the dead path and traffic settles on DERP; the rebuilt
// channel marks it ready again once it opens.
m.logf("webrtc: peer %v re-offered while %v; rebuilding connection", remoteDisco.ShortString(), state)
ps.peerConn.Close()
m.mu.Lock()
delete(m.peerConnectionsByPeer, ps.peer)
delete(m.peerConnectionsByDisco, remoteDisco)
m.mu.Unlock()
exists = false
default:
// Any other transitional signaling state; ignore, let it settle.
m.logf("webrtc: ignoring offer from %v in unexpected signaling state %v", remoteDisco.ShortString(), ps.peerConn.SignalingState())
return
}
}
if !exists {
var ok bool
ps, ok = m.newAnswererConn(remoteDisco)
if !ok {
return
}
}
if err := ps.peerConn.SetRemoteDescription(*offer); err != nil {
m.logf("webrtc: failed to set remote description: %v", err)
ps.peerConn.Close()
m.removeIfCurrent(ps.peer, remoteDisco, ps)
return
}
m.markRemoteDescSet(ps)
// Create answer
answer, err := ps.peerConn.CreateAnswer(nil)
if err != nil {
m.logf("webrtc: failed to create answer: %v", err)
ps.peerConn.Close()
m.removeIfCurrent(ps.peer, remoteDisco, ps)
return
}
if err := ps.peerConn.SetLocalDescription(answer); err != nil {
m.logf("webrtc: failed to set local description: %v", err)
ps.peerConn.Close()
m.removeIfCurrent(ps.peer, remoteDisco, ps)
return
}
// Send answer via signaling
if err := m.sendAnswer(remoteDisco, &answer); err != nil {
m.logf("webrtc: failed to send answer: %v", err)
ps.peerConn.Close()
m.removeIfCurrent(ps.peer, remoteDisco, ps)
return
}
m.logf("webrtc: sent answer to peer %v", remoteDisco.ShortString())
}
// newAnswererConn creates the peerState for an incoming offer from a peer we
// don't yet have a connection with. It returns ok=false if the peer is unknown
// or the peer connection could not be created.
func (m *manager) newAnswererConn(remoteDisco key.DiscoPublic) (*peerState, bool) {
peer, ok := m.b.PeerForDisco(remoteDisco)
if !ok {
m.logf("webrtc: received offer from unknown peer %v with no endpoint", remoteDisco.ShortString())
return nil, false
}
m.logf("webrtc: received offer from peer %v, creating answerer connection", remoteDisco.ShortString())
peerConn, err := m.api.NewPeerConnection(iceConfig)
if err != nil {
m.logf("webrtc: failed to create peer connection for incoming offer: %v", err)
return nil, false
}
ps := &peerState{
peer: peer,
peerConn: peerConn,
remoteDisco: remoteDisco,
remoteNodeKey: peer.NodeKey(),
state: stateConnecting,
}
m.mu.Lock()
m.peerConnectionsByPeer[peer] = ps
m.peerConnectionsByDisco[remoteDisco] = ps
m.mu.Unlock()
m.setupPeerConnHandlers(ps)
// For the answerer, we wait for the data channel from the offerer.
peerConn.OnDataChannel(func(dc *webrtc.DataChannel) {
m.logf("webrtc: received data channel from peer %v", remoteDisco.ShortString())
ps.dataChannel.Store(dc)
m.setupDataChannel(ps, dc, remoteDisco, peer)
})
return ps, true
}
// handleRemoteAnswer processes an incoming answer from a peer.
func (m *manager) handleRemoteAnswer(remoteDisco key.DiscoPublic, answer *webrtc.SessionDescription) {
m.mu.Lock()
ps, exists := m.peerConnectionsByDisco[remoteDisco]
m.mu.Unlock()
if !exists {
m.logf("webrtc: received answer from unknown peer %v", remoteDisco.ShortString())
return
}
if err := ps.peerConn.SetRemoteDescription(*answer); err != nil {
m.logf("webrtc: failed to set remote description: %v", err)
return
}
m.markRemoteDescSet(ps)
m.logf("webrtc: set remote description for peer %v", remoteDisco.ShortString())
}
// handleRemoteCandidate processes an incoming ICE candidate from a peer.
func (m *manager) handleRemoteCandidate(remoteDisco key.DiscoPublic, candidate *webrtc.ICECandidateInit) {
m.mu.Lock()
ps, exists := m.peerConnectionsByDisco[remoteDisco]
if exists && !ps.remoteDescSet {
// Remote description not set yet, so buffer the candidate and apply it
// once SetRemoteDescription is called (see markRemoteDescSet). Cap the
// buffer so a peer that streams candidates but never sends an answer
// cannot grow it without bound.
if len(ps.pendingCandidates) >= maxPendingCandidates {
m.mu.Unlock()
m.logf("webrtc: dropping ICE candidate for peer %v (pending buffer full)", remoteDisco.ShortString())
return
}
ps.pendingCandidates = append(ps.pendingCandidates, *candidate)
m.mu.Unlock()
m.logf("webrtc: buffered ICE candidate for peer %v (remote desc not yet set)", remoteDisco.ShortString())
return
}
m.mu.Unlock()
if !exists {
m.logf("webrtc: received candidate from unknown peer %v", remoteDisco.ShortString())
return
}
if err := ps.peerConn.AddICECandidate(*candidate); err != nil {
m.logf("webrtc: failed to add ICE candidate %q for peer %v: %v",
candidate.Candidate, remoteDisco.ShortString(), err)
return
}
m.logf("webrtc: added ICE candidate for peer %v", remoteDisco.ShortString())
}
// markRemoteDescSet marks ps as having a remote description set and flushes
// any ICE candidates that arrived before SetRemoteDescription was called.
// Must be called after SetRemoteDescription succeeds, without holding m.mu.
func (m *manager) markRemoteDescSet(ps *peerState) {
m.mu.Lock()
ps.remoteDescSet = true
pending := ps.pendingCandidates
ps.pendingCandidates = nil
m.mu.Unlock()
for i := range pending {
if err := ps.peerConn.AddICECandidate(pending[i]); err != nil {
m.logf("webrtc: failed to add buffered ICE candidate %q for peer %v: %v",
pending[i].Candidate, ps.remoteDisco.ShortString(), err)
}
}
if len(pending) > 0 {
m.logf("webrtc: flushed %d buffered ICE candidates for peer %v",
len(pending), ps.remoteDisco.ShortString())
}
}
// handleLocalICECandidate sends a local ICE candidate to a peer via signaling.
func (m *manager) handleLocalICECandidate(ps *peerState, candidate *webrtc.ICECandidate) {
candidateInit := candidate.ToJSON()
// Don't send a candidate that marshaled to the empty "candidate:" string
// (e.g. the end-of-candidates marker). The receiving peer's addIceCandidate
// rejects it, which is harmless but logs a per-connection error on the peer;
// we drop it here purely to avoid that noise.
if candidateInit.Candidate == "" || candidateInit.Candidate == "candidate:" {
return
}
// Pin the candidate to our single media section. We only ever create one
// DataChannel and no media tracks, so every peer's SDP has exactly one
// m-line, addressed as sdpMid "0" / sdpMLineIndex 0. pion's js
// ICECandidate.ToJSON() does not carry these through from the browser
// (valueToICECandidate drops them), which leaves the receiving browser's
// RTCPeerConnection.addIceCandidate unable to match the candidate to a
// media section and it rejects it with "Error processing ICE candidate".
// Setting them explicitly is correct here and unblocks the browser path.
mid := "0"
mline := uint16(0)
candidateInit.SDPMid = &mid
candidateInit.SDPMLineIndex = &mline
if err := m.sendCandidate(ps.remoteDisco, &candidateInit); err != nil {
m.logf("webrtc: failed to send candidate: %v", err)
return
}
m.logf("webrtc: sent ICE candidate to peer %v", ps.remoteDisco.ShortString())
}
+104
View File
@@ -0,0 +1,104 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package webrtc implements the WebRTC connectivity path for
// magicsock. It registers itself with magicsock from its init via
// [magicsock.HookNewWebRTCManager], so magicsock has no compile-time
// dependency on pion/WebRTC; the pion dependency is linked in only when this
// package is imported.
//
// The package reaches magicsock through the [magicsock.WebRTCBackend] and
// [magicsock.WebRTCPeer] interfaces (one peer per endpoint), giving it the
// minimal set of capabilities it needs (send disco signaling, deliver received
// packets, and mutate a peer's best path) without exposing magicsock
// internals.
package webrtc
import (
"sync"
"time"
"github.com/pion/webrtc/v4"
"tailscale.com/types/key"
"tailscale.com/types/logger"
"tailscale.com/wgengine/magicsock"
)
// manager manages WebRTC connections for magicsock.
type manager struct {
logf logger.Logf
b magicsock.WebRTCBackend
mu sync.RWMutex
peerConnectionsByPeer map[magicsock.WebRTCPeer]*peerState
peerConnectionsByDisco map[key.DiscoPublic]*peerState
// Control channels
startConnectionCh chan magicsock.WebRTCPeer
closeCh chan struct{}
runLoopStoppedCh chan struct{}
closeOnce sync.Once
// WebRTC API configuration
api *webrtc.API
}
// newManager is the [magicsock.HookNewWebRTCManager] implementation. It creates
// a WebRTC manager using disco-based signaling and returns it as a
// [magicsock.WebRTCManager]. On failure it returns an untyped nil interface
// (not a typed nil *manager) so magicsock's nil check behaves correctly.
func newManager(b magicsock.WebRTCBackend) magicsock.WebRTCManager {
m := newManagerBase(b)
go m.runLoop()
return m
}
// Close shuts down the WebRTC manager. It is idempotent and safe to call more
// than once (Conn.Close may run multiple times).
func (m *manager) Close() error {
m.closeOnce.Do(func() {
// Signal runLoop to stop
close(m.closeCh)
// Wait for runLoop to finish with timeout
select {
case <-m.runLoopStoppedCh:
case <-time.After(2 * time.Second):
m.logf("webrtc: close timed out, forcing shutdown")
}
m.mu.Lock()
defer m.mu.Unlock()
// Close all peer connections
for _, ps := range m.peerConnectionsByPeer {
if ps.peerConn != nil {
ps.peerConn.Close()
}
}
m.peerConnectionsByPeer = nil
m.peerConnectionsByDisco = nil
})
return nil
}
// runLoop is the main event loop for the WebRTC manager.
func (m *manager) runLoop() {
defer close(m.runLoopStoppedCh)
retryTicker := time.NewTicker(15 * time.Second)
defer retryTicker.Stop()
for {
select {
case p := <-m.startConnectionCh:
m.handleStartConnection(p)
case <-retryTicker.C:
m.retryFailedConnections()
case <-m.closeCh:
return
}
}
}
+1 -1
View File
@@ -173,4 +173,4 @@
});
};
}
# nix-direnv cache busting line: sha256-jrznfL26qyAGix9DVkmh0sLU2VvqeTNMdi6mPu9RlxM=
# nix-direnv cache busting line: sha256-n6gM53lsiy+0W/6hZsAZO5vHT0QhO2Xlw0n4Tg5xf3k=
+2 -2
View File
@@ -4,7 +4,7 @@
"sri": "sha256-sdlgTLTkdhTvGCeQTwAsR+Do8rNwBzBUp5QPc09BPgI="
},
"vendor": {
"goModSum": "sha256-DN+aQ2obBIjmj9TtvEA0+DYf+wIvpJ+MTg2+ZvYczkw=",
"sri": "sha256-jrznfL26qyAGix9DVkmh0sLU2VvqeTNMdi6mPu9RlxM="
"goModSum": "sha256-20ZG83WiS4q9EBkYwfhdh5P9p4C/VEutch0RxA/0Xws=",
"sri": "sha256-n6gM53lsiy+0W/6hZsAZO5vHT0QhO2Xlw0n4Tg5xf3k="
}
}
+17
View File
@@ -97,6 +97,7 @@ require (
github.com/miekg/dns v1.1.73
github.com/mitchellh/go-ps v1.0.0
github.com/peterbourgon/ff/v3 v3.4.0
github.com/pion/webrtc/v4 v4.2.18
github.com/pires/go-proxyproto v0.15.0
github.com/pkg/errors v0.9.1
github.com/pkg/sftp v1.13.11
@@ -447,6 +448,21 @@ require (
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
github.com/peterbourgon/diskv v2.0.1+incompatible // indirect
github.com/pierrec/lz4/v4 v4.1.26 // indirect
github.com/pion/datachannel v1.6.2 // indirect
github.com/pion/dtls/v3 v3.1.5 // indirect
github.com/pion/ice/v4 v4.4.0 // indirect
github.com/pion/interceptor v0.1.47 // indirect
github.com/pion/logging v0.2.4 // indirect
github.com/pion/mdns/v2 v2.1.0 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.17 // indirect
github.com/pion/rtp v1.10.5 // indirect
github.com/pion/sctp v1.11.1 // indirect
github.com/pion/sdp/v3 v3.0.19 // indirect
github.com/pion/srtp/v3 v3.0.12 // indirect
github.com/pion/stun/v3 v3.1.6 // indirect
github.com/pion/transport/v4 v4.0.2 // indirect
github.com/pion/turn/v5 v5.0.12 // indirect
github.com/pjbgf/sha1cd v0.6.0 // indirect
github.com/pkg/diff v0.0.0-20241224192749-4e6772a4315c // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
@@ -504,6 +520,7 @@ require (
github.com/uudashr/gocognit v1.2.1 // indirect
github.com/uudashr/iface v1.5.1 // indirect
github.com/vishvananda/netlink v1.3.1 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/xen0n/gosmopolitan v1.3.0 // indirect
+37
View File
@@ -916,6 +916,7 @@ github.com/nunnatsa/ginkgolinter v0.24.0 h1:Mp0EagluLFP98JatP6nqp/gGEoljNG97uf9A
github.com/nunnatsa/ginkgolinter v0.24.0/go.mod h1:2ZMRuzX6+3XXyY6UZOwb6n+MCocVGbkIsDBC4vuWz5c=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
github.com/onsi/ginkgo/v2 v2.32.1 h1:6tlvcDm/3sE8lGJbZ4+d4mO3RLy24/tQWOFzVSQNIfw=
github.com/onsi/ginkgo/v2 v2.32.1/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
github.com/onsi/gomega v1.43.0 h1:VlG/1FxqNxhSO+lq/OHBNaaqwiBK/mO8JbVkX9Y+FeU=
@@ -949,6 +950,40 @@ github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 h1:Ii+DKncOVM8Cu1H
github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5/go.mod h1:iIss55rKnNBTvrwdmkUpLnDpZoAHvWaiq5+iMmen4AE=
github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pion/datachannel v1.6.2 h1:7EXQ8TH3vTouBUdRWYbcX2edSx9Yj6k5zl5P+qyxEPc=
github.com/pion/datachannel v1.6.2/go.mod h1:pzbdAZvyGtXbcHM1hBbsFaOTf40lZizU/dNlvVOak6E=
github.com/pion/dtls/v3 v3.1.5 h1:9xJtVsHwMYeSjPp5Hh1FTis4DchnQWtnOa5o+6ygqfc=
github.com/pion/dtls/v3 v3.1.5/go.mod h1:gz1K4jg6c+fq86oQMH4pilpCEOEPwmEr2jY+VcF/mkU=
github.com/pion/ice/v4 v4.4.0 h1:wvHDDqimaC38Y7MVpD46Y63p246ChvXd87VKoLYS5b4=
github.com/pion/ice/v4 v4.4.0/go.mod h1:obAyD+J+Hzs7QA7Y8YXHp5uIn6gb7z87pKedXZkrcFU=
github.com/pion/interceptor v0.1.47 h1:yw8t5pJ2f8t78NgU+8EmxhaqYLXS7uFCC/tAGOaSDBo=
github.com/pion/interceptor v0.1.47/go.mod h1:7yoRBzaIDETPC6cIN8Zj9EyGqHv1ImOpcTFPha6MuOM=
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY=
github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtcp v1.2.17 h1:PxiT6L79yPZKtXIsXdG1eakBl6dtBj4x+4oVEL0DlSw=
github.com/pion/rtcp v1.2.17/go.mod h1:7kBpuBJaWwax4hzc/pgexY8vkOpvh8atgYDbaKZq0iU=
github.com/pion/rtp v1.10.5 h1:ip0HhO/wYZqQ4bKS+R99KnZh/GRCmIT0jDXikub7vlE=
github.com/pion/rtp v1.10.5/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk=
github.com/pion/sctp v1.11.1 h1:O4dIFyURw1KTST7w+gtD4gLeYXkhPa0xXLHMMoe/OSA=
github.com/pion/sctp v1.11.1/go.mod h1:7KFmTwLcoYgJs/Z+99nJvsWL0qDpuyloSI0RbAqlrz0=
github.com/pion/sdp/v3 v3.0.19 h1:1VMKs3gIkTQV5M3hNKfTAPrDXSNrYtOlmOD8+mSZUGQ=
github.com/pion/sdp/v3 v3.0.19/go.mod h1:dE5WOSlzXrtiE/iuZqe9n+AcEbOjtAd3k5m5NtlV/qU=
github.com/pion/srtp/v3 v3.0.12 h1:U7V17bckl7sI4mb3sepiojByDuBY0wNCqQE+6IlQBbc=
github.com/pion/srtp/v3 v3.0.12/go.mod h1:EeZOi/sd6glM1EXapg051gdNWO9yWT1YSsgQ4SlJkns=
github.com/pion/stun/v3 v3.1.6 h1:WnhsD0eHCiwCfKNkVx0VJJwr2Y3eV4Ueih3KJ+dfZy8=
github.com/pion/stun/v3 v3.1.6/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs=
github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM=
github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk=
github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM=
github.com/pion/turn/v5 v5.0.12 h1:6+b69ivQQXSlyfkp2AKripqD2k3W32qXK8QzCzpJWPI=
github.com/pion/turn/v5 v5.0.12/go.mod h1:CQACsRDJtjQ+6RSrGHrS2PCIerLwbW3uqXRqOvtjAFg=
github.com/pion/webrtc/v4 v4.2.18 h1:smA/3g6Gy4RohM0VIZ5KKY/12TQbxv3XFgpUMyb2EUI=
github.com/pion/webrtc/v4 v4.2.18/go.mod h1:vmzi6s+rvhoIuT94DPqivB+0xJXs9rG4QRD+4MgBtlY=
github.com/pires/go-proxyproto v0.15.0 h1:dTshmNbFm/D+0+sbrxUuddPOZ5Y0B7c5NhtsBkm6LqI=
github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH3lFW76orFh24=
github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
@@ -1204,6 +1239,8 @@ github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5J
github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0=
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
+12 -1
View File
@@ -687,7 +687,18 @@ table tbody tr:nth-child(even) td { background-color: #f5f5f5; }
if ps.Relay != "" && ps.CurAddr == "" {
f("relay <b>%s</b>", html.EscapeString(ps.Relay))
} else if ps.CurAddr != "" {
f("direct <b>%s</b>", html.EscapeString(ps.CurAddr))
// Check if this is a WebRTC connection (WebRTC magic IP).
if strings.HasPrefix(ps.CurAddr, tailcfg.WebRTCMagicIP+":") {
// Extract the actual remote address if present
if idx := strings.Index(ps.CurAddr, " ("); idx > 0 {
remoteAddr := ps.CurAddr[idx+2 : len(ps.CurAddr)-1] // Extract address from " (addr)"
f("webrtc <b>%s</b>", html.EscapeString(remoteAddr))
} else {
f("webrtc")
}
} else {
f("direct <b>%s</b>", html.EscapeString(ps.CurAddr))
}
}
}
+1 -1
View File
@@ -16,4 +16,4 @@
) {
src = ./.;
}).shellNix
# nix-direnv cache busting line: sha256-jrznfL26qyAGix9DVkmh0sLU2VvqeTNMdi6mPu9RlxM=
# nix-direnv cache busting line: sha256-n6gM53lsiy+0W/6hZsAZO5vHT0QhO2Xlw0n4Tg5xf3k=
+8
View File
@@ -3079,6 +3079,14 @@ const DerpMagicIP = "127.3.3.40"
var DerpMagicIPAddr = netip.MustParseAddr(DerpMagicIP)
// WebRTCMagicIP is a fake WireGuard endpoint IP address that means
// to use WebRTC data channel for packet transmission.
//
// Mnemonic: 127.3.3.41 is one above DerpMagicIP for WebRTC.
const WebRTCMagicIP = "127.3.3.41"
var WebRTCMagicIPAddr = netip.MustParseAddr(WebRTCMagicIP)
// EarlyNoise is the early payload that's sent over Noise but before the HTTP/2
// handshake when connecting to the coordination server.
//
+2
View File
@@ -3535,6 +3535,8 @@ func TestDeps(t *testing.T) {
"tailscale.com/feature/dnsresolvecache": "tsnet apps don't persist DNS resolutions to disk by default; import it explicitly if desired",
"tailscale.com/feature/remoteconfig": "tsnet should not depend on feature/remoteconfig",
"tailscale.com/feature/syspolicy": "tsnet should not depend on syspolicy",
"tailscale.com/feature/webrtc": "tsnet callers wanting the WebRTC path should import tailscale.com/feature/webrtc themselves",
"github.com/pion/webrtc/v4": "tsnet should not pull in the pion/WebRTC dependency tree; import tailscale.com/feature/webrtc explicitly if desired",
"tailscale.com/ipn/store/awsstore": "tsnet callers wanting AWS state storage should import awsstore themselves",
"tailscale.com/ipn/store/kubestore": "tsnet callers wanting Kubernetes state storage should import kubestore themselves",
"tailscale.com/wif": "tsnet callers wanting workload identity federation should import tailscale.com/feature/identityfederation themselves",
+3
View File
@@ -66,6 +66,9 @@ var (
// suppressing/dropping inbound/outbound [disco.Ping] messages, forcing
// all peer communication over DERP or peer relay.
debugNeverDirectUDP = envknob.RegisterBool("TS_DEBUG_NEVER_DIRECT_UDP")
// debugNeverDirectWebRTC disables the WebRTC path, so peers that would
// otherwise use it fall back to DERP.
debugNeverDirectWebRTC = envknob.RegisterBool("TS_DEBUG_NEVER_DIRECT_WEBRTC")
// Hey you! Adding a new debugknob? Make sure to stub it out in the
// debugknobs_stubs.go file too.
)
+1
View File
@@ -32,3 +32,4 @@ func inTest() bool { return false }
func debugPeerMap() bool { return false }
func pretendpoints() []netip.AddrPort { return []netip.AddrPort{} }
func debugNeverDirectUDP() bool { return false }
func debugNeverDirectWebRTC() bool { return false }
+123 -20
View File
@@ -105,6 +105,13 @@ type endpoint struct {
expired bool // whether the node has expired
isWireguardOnly bool // whether the endpoint is WireGuard only. Must not be changed after initializing the endpont.
relayCapable bool // whether the node is capable of speaking via a [tailscale.com/net/udprelay.Server]
// webrtcChannelReady reports whether a WebRTC data channel to this peer is
// currently open (set by the WebRTC manager on channel open/close). While
// true, the heartbeat re-probes the WebRTC magic address so that a WebRTC
// path can be (re)selected even after a fallback to DERP or a relay path.
// Channel existence is event-driven; path selection remains pong-driven.
webrtcChannelReady bool
}
// udpRelayEndpointReady determines whether the given relay [addrQuality] should
@@ -754,6 +761,11 @@ func (de *endpoint) maybeProbeUDPLifetimeLocked() (afterInactivityFor time.Durat
if !de.bestAddr.ap.IsValid() {
return afterInactivityFor, false
}
if de.bestAddr.ap.Addr() == tailcfg.WebRTCMagicIPAddr {
// The WebRTC path is not a real UDP socket, so there are no stateful
// middleboxes whose UDP mapping lifetime we'd be probing.
return afterInactivityFor, false
}
epDisco := de.disco.Load()
if epDisco == nil {
// peer does not support disco
@@ -931,9 +943,17 @@ func (de *endpoint) heartbeat() {
udpAddr, _, _ := de.addrForSendLocked(now)
if udpAddr.ap.IsValid() {
// We have a preferred path. Ping that every 'heartbeatInterval'.
// This includes the WebRTC magic address: like a peer-relay (VNI)
// path, WebRTC is bestAddr-only (never in de.endpointState) but its
// liveness is driven by these disco pings over the data channel, not
// by pion's connection-state callbacks. startDiscoPingLocked and
// handlePongConnLocked special-case the magic address the same way
// they special-case vni.IsSet().
de.startDiscoPingLocked(udpAddr, now, pingHeartbeat, 0, nil)
}
de.maybeProbeWebRTCLocked(now)
if de.wantFullPingLocked(now) {
de.sendDiscoPingsLocked(now, true)
}
@@ -945,6 +965,29 @@ func (de *endpoint) heartbeat() {
de.heartBeatTimer = time.AfterFunc(heartbeatInterval, de.heartbeat)
}
// maybeProbeWebRTCLocked sends a disco ping to the WebRTC magic address when a
// data channel is open and WebRTC is not already our path, so that a pong can
// (re)promote it via betterAddr. It is the WebRTC analogue of relay path
// re-discovery: without it, promotion would be one-shot (only on channel open)
// and a single late or dropped pong would wedge the peer on DERP for the rest
// of the session, since the heartbeat only pings the current bestAddr.
func (de *endpoint) maybeProbeWebRTCLocked(now mono.Time) {
if !de.webrtcChannelReady {
return
}
// If WebRTC is already our best path, the heartbeat ping to bestAddr keeps
// it alive; nothing extra to do.
if de.bestAddr.ap.Addr() == tailcfg.WebRTCMagicIPAddr {
return
}
// A direct UDP path outranks WebRTC (see betterAddr). If we're on a trusted
// direct path, don't bother probing WebRTC.
if de.bestAddr.isDirect() && now.Before(de.trustBestAddrUntil) {
return
}
de.startDiscoPingLocked(epAddr{ap: netip.AddrPortFrom(tailcfg.WebRTCMagicIPAddr, webrtcMagicPort)}, now, pingHeartbeat, 0, nil)
}
// setHeartbeatDisabled sets heartbeatDisabled to the provided value.
func (de *endpoint) setHeartbeatDisabled(v bool) {
de.mu.Lock()
@@ -1147,7 +1190,13 @@ func (de *endpoint) send(buffs [][]byte, offset int) error {
}
}
var err error
if hasUDPTransport && udpAddr.ap.IsValid() {
// Check if this is a WebRTC address and route accordingly
if udpAddr.ap.IsValid() && udpAddr.ap.Addr() == tailcfg.WebRTCMagicIPAddr {
// Pack all buffs into one SCTP message. See sendWebRTCBatch for why.
if err = de.c.sendWebRTCBatch(de.publicKey, buffs, offset); err != nil {
return err
}
} else if hasUDPTransport && udpAddr.ap.IsValid() {
_, err = de.c.sendUDPBatch(udpAddr, buffs, offset)
// If the error is known to indicate that the endpoint is no longer
@@ -1187,6 +1236,10 @@ func (de *endpoint) send(buffs [][]byte, offset int) error {
}
}
if derpAddr.IsValid() {
// Traffic is flowing via DERP; opportunistically upgrade to WebRTC.
if mgr := de.c.webrtcMgr; mgr != nil {
mgr.EnsureConnecting(de)
}
allOk := true
var txBytes int
for _, buff := range buffs {
@@ -1359,7 +1412,12 @@ const (
// is interested in the result (such as a CLI "tailscale ping" or a c2n ping
// request, etc)
func (de *endpoint) startDiscoPingLocked(ep epAddr, now mono.Time, purpose discoPingPurpose, size int, resCB *pingResultAndCallback) {
if runtime.GOOS == "js" {
isWebRTC := ep.ap.Addr() == tailcfg.WebRTCMagicIPAddr
if runtime.GOOS == "js" && !isWebRTC {
// A browser has no UDP socket, so it can't disco-ping a real endpoint.
// The WebRTC data channel is a usable transport though, so a ping to
// the WebRTC magic address is valid on js: sendAddr routes it over the
// data channel and never touches a UDP socket.
return
}
if debugNeverDirectUDP() && !ep.vni.IsSet() && ep.ap.Addr() != tailcfg.DerpMagicIPAddr {
@@ -1370,7 +1428,7 @@ func (de *endpoint) startDiscoPingLocked(ep epAddr, now mono.Time, purpose disco
return
}
if purpose != pingCLI &&
!ep.vni.IsSet() { // de.endpointState is only relevant for direct/non-vni epAddr's
!ep.vni.IsSet() && !isWebRTC { // de.endpointState is only relevant for direct/non-vni, non-WebRTC epAddr's
st, ok := de.endpointState[ep.ap]
if !ok {
// Shouldn't happen. But don't ping an endpoint that's
@@ -1958,9 +2016,11 @@ func (de *endpoint) handlePongConnLocked(m *disco.Pong, di *discoInfo, src epAdd
de.c.reportExitNodePong(de.publicKey, now)
latency := now.Sub(sp.at)
if hasUDPTransport && !isDerp && !src.vni.IsSet() {
// Note: we check vni.isSet() as relay [epAddr]'s are not stored in
// endpointState, they are either de.bestAddr or not.
isWebRTC := src.ap.Addr() == tailcfg.WebRTCMagicIPAddr
if hasUDPTransport && !isDerp && !src.vni.IsSet() && !isWebRTC {
// Note: we check vni.isSet() and the WebRTC magic address as those
// [epAddr]'s are not stored in endpointState; they are either
// de.bestAddr or not. The promotion block below still runs for them.
st, ok := de.endpointState[sp.to.ap]
if !ok {
// This is no longer an endpoint we care about.
@@ -2034,10 +2094,13 @@ type epAddr struct {
vni packet.VirtualNetworkID // vni.IsSet() indicates if this [epAddr] involves a Geneve header
}
// isDirect returns true if e.ap is valid and not tailcfg.DerpMagicIPAddr,
// isDirect returns true if e.ap is valid and not tailcfg.DerpMagicIPAddr or WebRTCMagicIPAddr,
// and a VNI is not set.
func (e epAddr) isDirect() bool {
return e.ap.IsValid() && e.ap.Addr() != tailcfg.DerpMagicIPAddr && !e.vni.IsSet()
return e.ap.IsValid() &&
e.ap.Addr() != tailcfg.DerpMagicIPAddr &&
e.ap.Addr() != tailcfg.WebRTCMagicIPAddr &&
!e.vni.IsSet()
}
func (e epAddr) String() string {
@@ -2090,6 +2153,28 @@ func betterAddr(a, b addrQuality) bool {
return false
}
// WebRTC path priority: Direct UDP > WebRTC > Peer Relay/DERP
aIsWebRTC := a.ap.Addr() == tailcfg.WebRTCMagicIPAddr
bIsWebRTC := b.ap.Addr() == tailcfg.WebRTCMagicIPAddr
aIsDERP := a.ap.Addr() == tailcfg.DerpMagicIPAddr
bIsDERP := b.ap.Addr() == tailcfg.DerpMagicIPAddr
// Direct paths beat WebRTC
if a.isDirect() && bIsWebRTC {
return true
}
if b.isDirect() && aIsWebRTC {
return false
}
// WebRTC beats DERP and relay (VNI)
if aIsWebRTC && (bIsDERP || b.vni.IsSet()) {
return true
}
if bIsWebRTC && (aIsDERP || a.vni.IsSet()) {
return false
}
// Each address starts with a set of points (from 0 to 100) that
// represents how much faster they are than the highest-latency
// endpoint. For example, if a has latency 200ms and b has latency
@@ -2110,19 +2195,26 @@ func betterAddr(a, b addrQuality) bool {
// addresses, and prefer link-local unicast addresses over other types
// of private IP addresses since it's definitionally more likely that
// they'll be on the same network segment than a general private IP.
if a.ap.Addr().IsLoopback() {
aPoints += 50
} else if a.ap.Addr().IsLinkLocalUnicast() {
aPoints += 30
} else if a.ap.Addr().IsPrivate() {
aPoints += 20
//
// Exclude magic IPs (DERP, WebRTC) from these bonuses as they're not
// real network paths.
if !aIsDERP && !aIsWebRTC {
if a.ap.Addr().IsLoopback() {
aPoints += 50
} else if a.ap.Addr().IsLinkLocalUnicast() {
aPoints += 30
} else if a.ap.Addr().IsPrivate() {
aPoints += 20
}
}
if b.ap.Addr().IsLoopback() {
bPoints += 50
} else if b.ap.Addr().IsLinkLocalUnicast() {
bPoints += 30
} else if b.ap.Addr().IsPrivate() {
bPoints += 20
if !bIsDERP && !bIsWebRTC {
if b.ap.Addr().IsLoopback() {
bPoints += 50
} else if b.ap.Addr().IsLinkLocalUnicast() {
bPoints += 30
} else if b.ap.Addr().IsPrivate() {
bPoints += 20
}
}
// Prefer IPv6 for being a bit more robust, as long as
@@ -2254,7 +2346,18 @@ func (de *endpoint) populatePeerStatus(ps *ipnstate.PeerStatus) {
ps.PeerRelay = udpAddr.String()
} else {
ps.CurAddr = udpAddr.String()
// If this is a WebRTC connection, append the actual remote address
if udpAddr.ap.Addr() == tailcfg.WebRTCMagicIPAddr && de.c.webrtcMgr != nil {
if disco := de.disco.Load(); disco != nil {
if remoteAddr := de.c.webrtcMgr.GetRemoteAddr(disco.key()); remoteAddr != "" {
ps.CurAddr = fmt.Sprintf("%s (%s)", ps.CurAddr, remoteAddr)
}
}
}
}
} else {
// Not on a direct or WebRTC path; show the DERP relay being used.
ps.Relay = de.c.derpRegionCodeOfIDLocked(tailcfg.DERPRegionID(de.derpAddr.Port()))
}
}
+118 -7
View File
@@ -96,6 +96,7 @@ type Path string
const (
PathDirectIPv4 Path = "direct_ipv4"
PathDirectIPv6 Path = "direct_ipv6"
PathWebRTC Path = "webrtc"
PathDERP Path = "derp"
PathPeerRelayIPv4 Path = "peer_relay_ipv4"
PathPeerRelayIPv6 Path = "peer_relay_ipv6"
@@ -111,6 +112,14 @@ type pathLabel struct {
Path Path
}
// webrtcReadResult is the result of reading a packet from a WebRTC data channel.
// It is similar to derpReadResult but for WebRTC connections.
type webrtcReadResult struct {
n int // length of data in buf
src key.NodePublic // sender's node public key
buf []byte // packet data; nil signals the receiver to ignore this message
}
// metrics in wgengine contains the usermetrics counters for magicsock, it
// is however a bit special. All them metrics are labeled, but looking up
// the metric everytime we need to record it has an overhead, and includes
@@ -121,6 +130,7 @@ type metrics struct {
// labeled by the path the packet took.
inboundPacketsIPv4Total expvar.Int
inboundPacketsIPv6Total expvar.Int
inboundPacketsWebRTCTotal expvar.Int
inboundPacketsDERPTotal expvar.Int
inboundPacketsPeerRelayIPv4Total expvar.Int
inboundPacketsPeerRelayIPv6Total expvar.Int
@@ -129,6 +139,7 @@ type metrics struct {
// labeled by the path the packet took.
inboundBytesIPv4Total expvar.Int
inboundBytesIPv6Total expvar.Int
inboundBytesWebRTCTotal expvar.Int
inboundBytesDERPTotal expvar.Int
inboundBytesPeerRelayIPv4Total expvar.Int
inboundBytesPeerRelayIPv6Total expvar.Int
@@ -137,6 +148,7 @@ type metrics struct {
// labeled by the path the packet took.
outboundPacketsIPv4Total expvar.Int
outboundPacketsIPv6Total expvar.Int
outboundPacketsWebRTCTotal expvar.Int
outboundPacketsDERPTotal expvar.Int
outboundPacketsPeerRelayIPv4Total expvar.Int
outboundPacketsPeerRelayIPv6Total expvar.Int
@@ -145,6 +157,7 @@ type metrics struct {
// labeled by the path the packet took.
outboundBytesIPv4Total expvar.Int
outboundBytesIPv6Total expvar.Int
outboundBytesWebRTCTotal expvar.Int
outboundBytesDERPTotal expvar.Int
outboundBytesPeerRelayIPv4Total expvar.Int
outboundBytesPeerRelayIPv6Total expvar.Int
@@ -211,6 +224,10 @@ type Conn struct {
// It must have buffer size > 0; see issue 3736.
derpRecvCh chan derpReadResult
// webrtcRecvCh is used by receiveWebRTC to read WebRTC messages.
// It must have buffer size > 0, similar to derpRecvCh.
webrtcRecvCh chan webrtcReadResult
// bind is the wireguard-go conn.Bind for Conn.
bind *connBind
@@ -334,6 +351,11 @@ type Conn struct {
// [tailscale.com/net/udprelay.Server] endpoints.
relayManager relayManager
// webrtcMgr manages WebRTC connections for peers. It is nil unless a program
// blank-imports feature/webrtc (the feature is opt-in), and also if WebRTC
// failed to initialize.
webrtcMgr WebRTCManager
// discoInfo is the state for an active peer DiscoKey.
discoInfo map[key.DiscoPublic]*discoInfo
@@ -580,7 +602,8 @@ func newConn(logf logger.Logf) *Conn {
discoPrivate := key.NewDisco()
c := &Conn{
logf: logf,
derpRecvCh: make(chan derpReadResult, 1), // must be buffered, see issue 3736
derpRecvCh: make(chan derpReadResult, 1), // must be buffered, see issue 3736
webrtcRecvCh: make(chan webrtcReadResult, 64), // must be buffered, similar to derpRecvCh
derpStarted: make(chan struct{}),
peerLastDerp: make(map[key.NodePublic]tailcfg.DERPRegionID),
peerMap: newPeerMap(),
@@ -737,6 +760,11 @@ func NewConn(opts Options) (*Conn, error) {
}
c.logf("magicsock: disco key = %v", c.discoAtomic.Short())
// Initialize the WebRTC manager (disco-based signaling) if feature/webrtc
// is linked in. No-op otherwise.
c.initWebRTC()
return c, nil
}
@@ -746,6 +774,7 @@ func NewConn(opts Options) (*Conn, error) {
func registerMetrics(reg *usermetric.Registry) *metrics {
pathDirectV4 := pathLabel{Path: PathDirectIPv4}
pathDirectV6 := pathLabel{Path: PathDirectIPv6}
pathWebRTC := pathLabel{Path: PathWebRTC}
pathDERP := pathLabel{Path: PathDERP}
pathPeerRelayV4 := pathLabel{Path: PathPeerRelayIPv4}
pathPeerRelayV6 := pathLabel{Path: PathPeerRelayIPv6}
@@ -780,21 +809,25 @@ func registerMetrics(reg *usermetric.Registry) *metrics {
// Map clientmetrics to the usermetric counters.
metricRecvDataPacketsIPv4.Register(&m.inboundPacketsIPv4Total)
metricRecvDataPacketsIPv6.Register(&m.inboundPacketsIPv6Total)
metricRecvDataPacketsWebRTC.Register(&m.inboundPacketsWebRTCTotal)
metricRecvDataPacketsDERP.Register(&m.inboundPacketsDERPTotal)
metricRecvDataPacketsPeerRelayIPv4.Register(&m.inboundPacketsPeerRelayIPv4Total)
metricRecvDataPacketsPeerRelayIPv6.Register(&m.inboundPacketsPeerRelayIPv6Total)
metricRecvDataBytesIPv4.Register(&m.inboundBytesIPv4Total)
metricRecvDataBytesIPv6.Register(&m.inboundBytesIPv6Total)
metricRecvDataBytesWebRTC.Register(&m.inboundBytesWebRTCTotal)
metricRecvDataBytesDERP.Register(&m.inboundBytesDERPTotal)
metricRecvDataBytesPeerRelayIPv4.Register(&m.inboundBytesPeerRelayIPv4Total)
metricRecvDataBytesPeerRelayIPv6.Register(&m.inboundBytesPeerRelayIPv6Total)
metricSendDataPacketsIPv4.Register(&m.outboundPacketsIPv4Total)
metricSendDataPacketsIPv6.Register(&m.outboundPacketsIPv6Total)
metricSendDataPacketsWebRTC.Register(&m.outboundPacketsWebRTCTotal)
metricSendDataPacketsDERP.Register(&m.outboundPacketsDERPTotal)
metricSendDataPacketsPeerRelayIPv4.Register(&m.outboundPacketsPeerRelayIPv4Total)
metricSendDataPacketsPeerRelayIPv6.Register(&m.outboundPacketsPeerRelayIPv6Total)
metricSendDataBytesIPv4.Register(&m.outboundBytesIPv4Total)
metricSendDataBytesIPv6.Register(&m.outboundBytesIPv6Total)
metricSendDataBytesWebRTC.Register(&m.outboundBytesWebRTCTotal)
metricSendDataBytesDERP.Register(&m.outboundBytesDERPTotal)
metricSendDataBytesPeerRelayIPv4.Register(&m.outboundBytesPeerRelayIPv4Total)
metricSendDataBytesPeerRelayIPv6.Register(&m.outboundBytesPeerRelayIPv6Total)
@@ -806,24 +839,28 @@ func registerMetrics(reg *usermetric.Registry) *metrics {
inboundPacketsTotal.Set(pathDirectV4, &m.inboundPacketsIPv4Total)
inboundPacketsTotal.Set(pathDirectV6, &m.inboundPacketsIPv6Total)
inboundPacketsTotal.Set(pathWebRTC, &m.inboundPacketsWebRTCTotal)
inboundPacketsTotal.Set(pathDERP, &m.inboundPacketsDERPTotal)
inboundPacketsTotal.Set(pathPeerRelayV4, &m.inboundPacketsPeerRelayIPv4Total)
inboundPacketsTotal.Set(pathPeerRelayV6, &m.inboundPacketsPeerRelayIPv6Total)
inboundBytesTotal.Set(pathDirectV4, &m.inboundBytesIPv4Total)
inboundBytesTotal.Set(pathDirectV6, &m.inboundBytesIPv6Total)
inboundBytesTotal.Set(pathWebRTC, &m.inboundBytesWebRTCTotal)
inboundBytesTotal.Set(pathDERP, &m.inboundBytesDERPTotal)
inboundBytesTotal.Set(pathPeerRelayV4, &m.inboundBytesPeerRelayIPv4Total)
inboundBytesTotal.Set(pathPeerRelayV6, &m.inboundBytesPeerRelayIPv6Total)
outboundPacketsTotal.Set(pathDirectV4, &m.outboundPacketsIPv4Total)
outboundPacketsTotal.Set(pathDirectV6, &m.outboundPacketsIPv6Total)
outboundPacketsTotal.Set(pathWebRTC, &m.outboundPacketsWebRTCTotal)
outboundPacketsTotal.Set(pathDERP, &m.outboundPacketsDERPTotal)
outboundPacketsTotal.Set(pathPeerRelayV4, &m.outboundPacketsPeerRelayIPv4Total)
outboundPacketsTotal.Set(pathPeerRelayV6, &m.outboundPacketsPeerRelayIPv6Total)
outboundBytesTotal.Set(pathDirectV4, &m.outboundBytesIPv4Total)
outboundBytesTotal.Set(pathDirectV6, &m.outboundBytesIPv6Total)
outboundBytesTotal.Set(pathWebRTC, &m.outboundBytesWebRTCTotal)
outboundBytesTotal.Set(pathDERP, &m.outboundBytesDERPTotal)
outboundBytesTotal.Set(pathPeerRelayV4, &m.outboundBytesPeerRelayIPv4Total)
outboundBytesTotal.Set(pathPeerRelayV6, &m.outboundBytesPeerRelayIPv6Total)
@@ -838,21 +875,25 @@ func registerMetrics(reg *usermetric.Registry) *metrics {
func deregisterMetrics() {
metricRecvDataPacketsIPv4.UnregisterAll()
metricRecvDataPacketsIPv6.UnregisterAll()
metricRecvDataPacketsWebRTC.UnregisterAll()
metricRecvDataPacketsDERP.UnregisterAll()
metricRecvDataPacketsPeerRelayIPv4.UnregisterAll()
metricRecvDataPacketsPeerRelayIPv6.UnregisterAll()
metricRecvDataBytesIPv4.UnregisterAll()
metricRecvDataBytesIPv6.UnregisterAll()
metricRecvDataBytesWebRTC.UnregisterAll()
metricRecvDataBytesDERP.UnregisterAll()
metricRecvDataBytesPeerRelayIPv4.UnregisterAll()
metricRecvDataBytesPeerRelayIPv6.UnregisterAll()
metricSendDataPacketsIPv4.UnregisterAll()
metricSendDataPacketsIPv6.UnregisterAll()
metricSendDataPacketsWebRTC.UnregisterAll()
metricSendDataPacketsDERP.UnregisterAll()
metricSendDataPacketsPeerRelayIPv4.UnregisterAll()
metricSendDataPacketsPeerRelayIPv6.UnregisterAll()
metricSendDataBytesIPv4.UnregisterAll()
metricSendDataBytesIPv6.UnregisterAll()
metricSendDataBytesWebRTC.UnregisterAll()
metricSendDataBytesDERP.UnregisterAll()
metricSendDataBytesPeerRelayIPv4.UnregisterAll()
metricSendDataBytesPeerRelayIPv6.UnregisterAll()
@@ -1677,6 +1718,9 @@ func (c *Conn) sendUDPStd(addr netip.AddrPort, b []byte) (sent bool, err error)
// IPv6 address when the local machine doesn't have IPv6 support
// returns (false, nil); it's not an error, but nothing was sent.
func (c *Conn) sendAddr(addr netip.AddrPort, pubKey key.NodePublic, b []byte, isDisco bool, isGeneveEncap bool) (sent bool, err error) {
if addr.Addr() == tailcfg.WebRTCMagicIPAddr {
return c.sendWebRTC(addr, pubKey, b)
}
if addr.Addr() != tailcfg.DerpMagicIPAddr {
return c.sendUDP(addr, b, isDisco, isGeneveEncap)
}
@@ -2036,7 +2080,7 @@ func (c *Conn) sendDiscoMessage(dst epAddr, dstKey key.NodePublic, dstDisco key.
if !dstKey.IsZero() {
node = dstKey.ShortString()
}
c.dlogf("[v1] magicsock: disco: %v->%v (%v, %v) sent %v len %v\n", c.discoAtomic.Short(), dstDisco.ShortString(), node, derpStr(dst.String()), disco.MessageSummary(m), len(pkt))
c.dlogf("[v1] magicsock: disco: %v->%v (%v, %v) sent %v len %v\n", c.discoAtomic.Short(), dstDisco.ShortString(), node, pathStr(dst.String()), disco.MessageSummary(m), len(pkt))
}
if isDERP {
metricSentDiscoDERP.Add(1)
@@ -2076,6 +2120,7 @@ type discoRXPath string
const (
discoRXPathUDP discoRXPath = "UDP socket"
discoRXPathDERP discoRXPath = "DERP"
discoRXPathWebRTC discoRXPath = "WebRTC"
discoRXPathRawSocket discoRXPath = "raw socket"
)
@@ -2411,13 +2456,13 @@ func (c *Conn) handleDiscoMessage(msg []byte, src epAddr, shouldBeRelayHandshake
if isVia {
c.dlogf("[v1] magicsock: disco: %v<-%v via %v (%v, %v) got call-me-maybe-via, %d endpoints",
c.discoAtomic.Short(), epDisco.shortString(), via.ServerDisco.ShortString(),
ep.publicKey.ShortString(), derpStr(src.String()),
ep.publicKey.ShortString(), pathStr(src.String()),
len(via.AddrPorts))
c.relayManager.handleCallMeMaybeVia(ep, lastBest, lastBestIsTrusted, via)
} else {
c.dlogf("[v1] magicsock: disco: %v<-%v (%v, %v) got call-me-maybe, %d endpoints",
c.discoAtomic.Short(), epDisco.shortString(),
ep.publicKey.ShortString(), derpStr(src.String()),
ep.publicKey.ShortString(), pathStr(src.String()),
len(cmm.MyNumber))
go ep.handleCallMeMaybe(cmm)
}
@@ -2472,7 +2517,7 @@ func (c *Conn) handleDiscoMessage(msg []byte, src epAddr, shouldBeRelayHandshake
}
c.dlogf("[v1] magicsock: disco: %v<-%v (%v, %v) got %s, %d endpoints",
c.discoAtomic.Short(), epDisco.shortString(),
ep.publicKey.ShortString(), derpStr(src.String()),
ep.publicKey.ShortString(), pathStr(src.String()),
msgType,
len(resp.AddrPorts))
c.relayManager.handleRxDiscoMsg(c, resp, nodeKey, di.discoKey, src)
@@ -2488,7 +2533,7 @@ func (c *Conn) handleDiscoMessage(msg []byte, src epAddr, shouldBeRelayHandshake
} else {
c.dlogf("[v1] magicsock: disco: %v<-%v (%v, %v) got %s disco[0]=%v disco[1]=%v",
c.discoAtomic.Short(), epDisco.shortString(),
ep.publicKey.ShortString(), derpStr(src.String()),
ep.publicKey.ShortString(), pathStr(src.String()),
msgType,
req.ClientDisco[0].ShortString(), req.ClientDisco[1].ShortString())
}
@@ -2512,6 +2557,18 @@ func (c *Conn) handleDiscoMessage(msg []byte, src epAddr, shouldBeRelayHandshake
RxFromNodeKey: nodeKey,
Message: req,
})
case *disco.WebRTCSignal:
if !isDERP {
c.logf("[unexpected] WebRTC signaling message received via UDP, expected DERP only")
return
}
if c.webrtcMgr == nil {
return
}
// Dispatch to the WebRTC manager off the hot path; c.mu must not be
// held. The manager (feature/webrtc) owns the pion types; magicsock
// only forwards the signaling kind and JSON payload bytes.
go c.webrtcMgr.HandleSignal(sender, dm.Kind, dm.Payload)
}
return
}
@@ -2612,7 +2669,14 @@ func (c *Conn) handlePingLocked(dm *disco.Ping, src epAddr, di *discoInfo, derpN
// Remember this route if not present.
var dup bool
if isDerp {
isWebRTC := src.ap.Addr() == tailcfg.WebRTCMagicIPAddr
if isDerp || isWebRTC {
// Both DERP and WebRTC carry the sender's node key out of band
// (derpNodeSrc), so we resolve the endpoint by node key and reply to
// src directly. Unlike a real UDP ping, we must NOT addCandidateEndpoint
// the magic address: it isn't a discoverable UDP endpoint, and a
// synthetic endpointState entry would be clobbered on the next netmap
// update. WebRTC liveness is bestAddr-only, like a peer-relay path.
if _, ok := c.peerMap.endpointForNodeKey(derpNodeSrc); ok {
numNodes = 1
}
@@ -3531,6 +3595,7 @@ func (c *connBind) Open(ignoredPort uint16) ([]conn.ReceiveFunc, uint16, error)
fns = append(fns, c.receiveIPv4(), c.receiveIPv6())
}
fns = append(fns, c.mkReceiveDERPFunc())
fns = append(fns, c.receiveWebRTC)
// TODO: Combine receiveIPv4 and receiveIPv6 and receiveIP into a single
// closure that closes over a *RebindingUDPConn?
return fns, c.LocalPort(), nil
@@ -3574,6 +3639,8 @@ func (c *connBind) Close() error {
// which will then check connBind.Closed.
// connBind.Closed takes c.mu, but c.derpRecvCh is buffered.
c.derpRecvCh <- derpReadResult{}
// Likewise unblock receiveWebRTC, whose channel is also buffered.
c.webrtcRecvCh <- webrtcReadResult{}
return nil
}
@@ -3595,6 +3662,16 @@ func (c *Conn) Close() error {
// Conn.Close() behaviors.
c.eventClient.Close()
// Close the WebRTC manager before taking c.mu. Its Close waits for its run
// loop to stop, and that loop can still be running SendSignal, which takes
// c.mu. If we held c.mu here, that SendSignal would block on it while we
// block waiting for the loop, stalling shutdown until the manager's 2s
// timeout. c.webrtcMgr is write-once (set in initWebRTC before the Conn is
// used) so reading it without c.mu is safe, and manager.Close is idempotent.
if c.webrtcMgr != nil {
c.webrtcMgr.Close()
}
c.mu.Lock()
defer c.mu.Unlock()
if c.closed {
@@ -3613,6 +3690,11 @@ func (c *Conn) Close() error {
ep.stopAndReset()
})
// Note: we deliberately do not close(c.webrtcRecvCh). Like derpRecvCh, it
// is never closed; receiveWebRTC unblocks via the sentinel sent from
// connBind.Close and exits by observing connBind.isClosed. Closing it here
// would race with that sentinel send and panic.
c.closed = true
c.connCtxCancel()
c.closeAllDerpLocked("conn-close")
@@ -4183,9 +4265,20 @@ func trySetUDPSocketOptions(pconn nettype.PacketConn, logf logger.Logf) {
}
}
// pathStr formats endpoint addresses for display, replacing magic IPs with readable names.
// It replaces DERP IPs with "derp-" and WebRTC IPs with "webrtc-".
func pathStr(s string) string {
s = derpStr(s)
s = webrtcStr(s)
return s
}
// derpStr replaces DERP IPs in s with "derp-".
func derpStr(s string) string { return strings.ReplaceAll(s, "127.3.3.40:", "derp-") }
// webrtcStr replaces WebRTC IPs in s with "webrtc-".
func webrtcStr(s string) string { return strings.ReplaceAll(s, "127.3.3.41:", "webrtc-") }
// epAddrEndpointCache is a mutex-free single-element cache, mapping from
// a single [epAddr] to a single [*endpoint].
type epAddrEndpointCache struct {
@@ -4266,11 +4359,13 @@ var (
metricRecvDataPacketsDERP = clientmetric.NewAggregateCounter("magicsock_recv_data_derp")
metricRecvDataPacketsIPv4 = clientmetric.NewAggregateCounter("magicsock_recv_data_ipv4")
metricRecvDataPacketsIPv6 = clientmetric.NewAggregateCounter("magicsock_recv_data_ipv6")
metricRecvDataPacketsWebRTC = clientmetric.NewAggregateCounter("magicsock_recv_data_webrtc")
metricRecvDataPacketsPeerRelayIPv4 = clientmetric.NewAggregateCounter("magicsock_recv_data_peer_relay_ipv4")
metricRecvDataPacketsPeerRelayIPv6 = clientmetric.NewAggregateCounter("magicsock_recv_data_peer_relay_ipv6")
metricSendDataPacketsDERP = clientmetric.NewAggregateCounter("magicsock_send_data_derp")
metricSendDataPacketsIPv4 = clientmetric.NewAggregateCounter("magicsock_send_data_ipv4")
metricSendDataPacketsIPv6 = clientmetric.NewAggregateCounter("magicsock_send_data_ipv6")
metricSendDataPacketsWebRTC = clientmetric.NewAggregateCounter("magicsock_send_data_webrtc")
metricSendDataPacketsPeerRelayIPv4 = clientmetric.NewAggregateCounter("magicsock_send_data_peer_relay_ipv4")
metricSendDataPacketsPeerRelayIPv6 = clientmetric.NewAggregateCounter("magicsock_send_data_peer_relay_ipv6")
@@ -4278,11 +4373,13 @@ var (
metricRecvDataBytesDERP = clientmetric.NewAggregateCounter("magicsock_recv_data_bytes_derp")
metricRecvDataBytesIPv4 = clientmetric.NewAggregateCounter("magicsock_recv_data_bytes_ipv4")
metricRecvDataBytesIPv6 = clientmetric.NewAggregateCounter("magicsock_recv_data_bytes_ipv6")
metricRecvDataBytesWebRTC = clientmetric.NewAggregateCounter("magicsock_recv_data_bytes_webrtc")
metricRecvDataBytesPeerRelayIPv4 = clientmetric.NewAggregateCounter("magicsock_recv_data_bytes_peer_relay_ipv4")
metricRecvDataBytesPeerRelayIPv6 = clientmetric.NewAggregateCounter("magicsock_recv_data_bytes_peer_relay_ipv6")
metricSendDataBytesDERP = clientmetric.NewAggregateCounter("magicsock_send_data_bytes_derp")
metricSendDataBytesIPv4 = clientmetric.NewAggregateCounter("magicsock_send_data_bytes_ipv4")
metricSendDataBytesIPv6 = clientmetric.NewAggregateCounter("magicsock_send_data_bytes_ipv6")
metricSendDataBytesWebRTC = clientmetric.NewAggregateCounter("magicsock_send_data_bytes_webrtc")
metricSendDataBytesPeerRelayIPv4 = clientmetric.NewAggregateCounter("magicsock_send_data_bytes_peer_relay_ipv4")
metricSendDataBytesPeerRelayIPv6 = clientmetric.NewAggregateCounter("magicsock_send_data_bytes_peer_relay_ipv6")
@@ -4433,6 +4530,20 @@ func (c *Conn) GetDERPRegionLatency() map[tailcfg.DERPRegionID]time.Duration {
return c.netChecker.RecentRegionLatency()
}
// findEndpointByDisco returns the first endpoint with the given disco key, or nil if not found.
func (c *Conn) findEndpointByDisco(dk key.DiscoPublic) *endpoint {
// peerMap access requires c.mu; forEachEndpointWithDiscoKey reads byNodeKey,
// which is only guarded by c.mu (not peerMap's own nodesMu).
c.mu.Lock()
defer c.mu.Unlock()
var found *endpoint
c.peerMap.forEachEndpointWithDiscoKey(dk, func(ep *endpoint) bool {
found = ep
return false // stop after first match
})
return found
}
// lazyEndpoint is a wireguard [conn.Endpoint] for when magicsock received a
// non-disco (presumably WireGuard) packet from a UDP address from which we
// can't map to a Tailscale peer. But WireGuard most likely can, once it
+132
View File
@@ -0,0 +1,132 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package magicsock
import (
"net/netip"
"tailscale.com/disco"
"tailscale.com/feature"
"tailscale.com/types/key"
)
// This file defines magicsock's extension point for the WebRTC
// connectivity path. The implementation lives in feature/webrtc, which
// registers itself via HookNewWebRTCManager from its init. magicsock itself
// has no dependency on pion/WebRTC; the feature is opt-in, linked in only when
// a program blank-imports feature/webrtc. When it isn't, the hook is unset and
// webrtcMgr stays nil.
// HookNewWebRTCManager, if set by feature/webrtc, constructs a [WebRTCManager]
// for the given [WebRTCBackend]. It returns nil if the manager fails to
// initialize.
var HookNewWebRTCManager feature.Hook[func(WebRTCBackend) WebRTCManager]
// WebRTCManager manages WebRTC connections for a [Conn]. It is implemented by
// feature/webrtc and consumed by magicsock via the webrtcMgr field.
type WebRTCManager interface {
// Close shuts down the manager and all its peer connections.
Close() error
// EnsureConnecting starts (or retries) a WebRTC connection to peer if one
// is not already in progress or established. Safe to call from the hot
// send path.
EnsureConnecting(peer WebRTCPeer)
// SendPacket sends b to the connected WebRTC peer identified by dst.
SendPacket(dst key.DiscoPublic, b []byte) error
// GetRemoteAddr returns the selected remote address (host:port) for a
// connected WebRTC peer, or "" if not connected. The host may be a numeric
// IP or an mDNS ".local" name.
GetRemoteAddr(dst key.DiscoPublic) string
// HandleSignal dispatches a WebRTC signaling message (of the given kind,
// with a JSON-encoded payload) received over disco from fromDisco.
HandleSignal(fromDisco key.DiscoPublic, kind disco.WebRTCSignalKind, payload []byte)
}
// WebRTCBackend is the set of magicsock capabilities the WebRTC manager needs.
// It is implemented by *Conn. All methods must be safe for concurrent use.
type WebRTCBackend interface {
// LocalDiscoKey returns this node's current disco public key.
LocalDiscoKey() key.DiscoPublic
// PeerForDisco returns the [WebRTCPeer] for the given disco key, or
// ok=false if no endpoint is known for it.
PeerForDisco(key.DiscoPublic) (p WebRTCPeer, ok bool)
// SendSignal sends a WebRTC signaling disco message to the peer identified
// by dst, over DERP.
SendSignal(dst key.DiscoPublic, msg disco.Message) error
// DeliverPacket hands a decrypted WebRTC data-channel packet to the
// magicsock receive pipeline, attributed to the given source node key.
DeliverPacket(b []byte, src key.NodePublic)
// DisableWebRTC reports whether WebRTC should be suppressed (e.g. the
// TS_DEBUG_ALWAYS_USE_DERP debug knob is set).
DisableWebRTC() bool
// Logf logs a message.
Logf(format string, args ...any)
}
// WebRTCPeer abstracts a single magicsock endpoint for the WebRTC manager. A
// WebRTCPeer is used as a map key by the manager, so implementations must be
// comparable and stable for the lifetime of the endpoint. It is implemented by
// *endpoint.
type WebRTCPeer interface {
// DiscoKey returns the peer's current disco public key, or ok=false if the
// peer has no disco key yet.
DiscoKey() (dk key.DiscoPublic, ok bool)
// NodeKey returns the peer's node public key (used for WireGuard).
NodeKey() key.NodePublic
// NodeAddr returns the peer's Tailscale IP, for logging only.
NodeAddr() netip.Addr
// IsJS reports whether the peer runs on a JavaScript/Wasm platform (its
// last-known Hostinfo OS is "js"). WebRTC is only attempted when at least
// one side of a connection is a JS peer; native-to-native peers use direct
// UDP or DERP instead.
IsJS() bool
// DERPReady reports whether the peer currently has a valid DERP address,
// which is required before signaling can succeed.
DERPReady() bool
// SetWebRTCChannelReady reports whether a WebRTC data channel to the peer is
// currently open. While open, magicsock re-probes the WebRTC path each disco
// heartbeat so it can be selected, and re-selected after any fallback, based
// on disco pong evidence. Path selection and liveness are otherwise driven
// entirely by disco ping/pong, not by this signal.
SetWebRTCChannelReady(ready bool)
}
// WebRTCBatchMagic is the first byte of a batched WebRTC SCTP message, framed
// as [magic][2-byte BE len][pkt]... It must not collide with the first byte of
// the other things that can arrive on the data channel: WireGuard packets start
// with 0x01–0x04 and disco packets start with 0x54 ('T'). 'W' collides with
// neither. It is the single source of truth for the framing byte, shared with
// feature/webrtc's receive path.
const WebRTCBatchMagic = byte('W')
// initWebRTC initializes the WebRTC manager if feature/webrtc is linked in
// (i.e. it registered HookNewWebRTCManager from its init). It is a no-op,
// leaving c.webrtcMgr nil, otherwise. magicsock has no compile-time dependency
// on pion/WebRTC; the hook being unset is what disables the feature, so no
// build tag is needed here.
func (c *Conn) initWebRTC() {
newMgr, ok := HookNewWebRTCManager.GetOk()
if !ok {
return
}
c.logf("magicsock: initializing WebRTC with disco signaling")
c.webrtcMgr = newMgr(c)
if c.webrtcMgr == nil {
c.logf("magicsock: failed to initialize WebRTC manager")
}
}
+313
View File
@@ -0,0 +1,313 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package magicsock
import (
"encoding/binary"
"fmt"
"net"
"net/netip"
"github.com/tailscale/wireguard-go/conn"
"tailscale.com/disco"
"tailscale.com/tailcfg"
"tailscale.com/tstime/mono"
"tailscale.com/types/key"
)
// This file implements [WebRTCBackend] on *Conn and the magicsock-side send
// and receive plumbing for the WebRTC path. The pion-using logic lives in
// feature/webrtc; everything here deals only in magicsock types.
// webrtcMagicPort is the fixed port used in the synthetic [epAddr] that
// represents a WebRTC path. The magic IP identifies the path as WebRTC; the
// port is arbitrary but stable.
const webrtcMagicPort = 12345
// LocalDiscoKey implements [WebRTCBackend].
func (c *Conn) LocalDiscoKey() key.DiscoPublic { return c.DiscoPublicKey() }
// DisableWebRTC implements [WebRTCBackend]. WebRTC is suppressed when the
// operator has forced all traffic over DERP, or disabled the WebRTC path, for
// debugging. In either case affected peers fall back to DERP.
func (c *Conn) DisableWebRTC() bool { return debugAlwaysDERP() || debugNeverDirectWebRTC() }
// Logf implements [WebRTCBackend].
func (c *Conn) Logf(format string, args ...any) { c.logf(format, args...) }
// PeerForDisco implements [WebRTCBackend].
func (c *Conn) PeerForDisco(dk key.DiscoPublic) (WebRTCPeer, bool) {
if ep := c.findEndpointByDisco(dk); ep != nil {
return ep, true
}
return nil, false
}
// SendSignal implements [WebRTCBackend]. It routes a WebRTC signaling disco
// message to the peer identified by dst, via that peer's home DERP region.
func (c *Conn) SendSignal(dst key.DiscoPublic, m disco.Message) error {
// Find the endpoint and its home DERP address under the Conn lock.
c.mu.Lock()
var (
derpAddr netip.AddrPort
nodeKey key.NodePublic
found bool
)
c.peerMap.forEachEndpointWithDiscoKey(dst, func(ep *endpoint) bool {
ep.mu.Lock()
derpAddr = ep.derpAddr
ep.mu.Unlock()
nodeKey = ep.publicKey
found = true
return false // stop after first match
})
c.mu.Unlock()
if !found {
return fmt.Errorf("webrtc: no endpoint for disco key %v", dst.ShortString())
}
if !derpAddr.IsValid() {
return fmt.Errorf("webrtc: no DERP address for peer %v", dst.ShortString())
}
_, err := c.sendDiscoMessage(epAddr{ap: derpAddr}, nodeKey, dst, m, discoLog)
return err
}
// DeliverPacket implements [WebRTCBackend]. It queues a decrypted WebRTC
// data-channel packet for processing by wireguard-go through webrtcRecvCh.
func (c *Conn) DeliverPacket(b []byte, src key.NodePublic) {
// Copy into a fresh slice: b belongs to the reader goroutine's reusable
// buffer which will be overwritten on the next Read call.
pkt := make([]byte, len(b))
copy(pkt, b)
select {
case c.webrtcRecvCh <- webrtcReadResult{n: len(pkt), src: src, buf: pkt}:
case <-c.connCtx.Done():
default:
c.logf("webrtc: dropped packet from %v, receive channel full", src.ShortString())
}
}
// The following methods implement [WebRTCPeer] on *endpoint.
// DiscoKey implements [WebRTCPeer].
func (de *endpoint) DiscoKey() (key.DiscoPublic, bool) {
d := de.disco.Load()
if d == nil {
return key.DiscoPublic{}, false
}
return d.key(), true
}
// NodeKey implements [WebRTCPeer].
func (de *endpoint) NodeKey() key.NodePublic { return de.publicKey }
// NodeAddr implements [WebRTCPeer].
func (de *endpoint) NodeAddr() netip.Addr { return de.nodeAddr }
// IsJS implements [WebRTCPeer]. It reports whether the peer's last-known
// Hostinfo OS is "js", i.e. the peer is a browser/Wasm node (a node whose
// runtime.GOOS is "js").
func (de *endpoint) IsJS() bool {
de.c.mu.Lock()
defer de.c.mu.Unlock()
nv, ok := de.c.peersByID[de.nodeID]
if !ok || !nv.Valid() {
return false
}
hi := nv.Hostinfo()
return hi.Valid() && hi.OS() == "js"
}
// DERPReady implements [WebRTCPeer].
func (de *endpoint) DERPReady() bool {
de.mu.Lock()
defer de.mu.Unlock()
return de.derpAddr.IsValid()
}
// SetWebRTCChannelReady implements [WebRTCPeer]. It marks the WebRTC data
// channel to this peer as open (ready=true) or closed (ready=false).
//
// While ready, the disco heartbeat re-probes the WebRTC magic address every
// interval (see maybeProbeWebRTCLocked), so a WebRTC path can be selected, and
// re-selected after any fallback, purely on disco pong evidence. This is the
// analogue of relay path re-discovery (wantUDPRelayPathDiscoveryLocked): a
// one-shot promotion would wedge on DERP the first time a pong arrived late.
//
// On the ready=true transition we also send an immediate probe so promotion
// doesn't wait up to a full heartbeat interval.
func (de *endpoint) SetWebRTCChannelReady(ready bool) {
de.mu.Lock()
defer de.mu.Unlock()
de.webrtcChannelReady = ready
if ready {
de.maybeProbeWebRTCLocked(mono.Now())
}
}
// sendWebRTCBatch packs all buffs into a single SCTP message and sends it.
// Batching is the critical throughput optimization: the per-packet WebRTC
// path calls rwc.Write once per WireGuard packet, producing one SCTP message,
// one DTLS record, and one UDP send per packet. Packing N packets into one
// SCTP message reduces that to a single write, the same advantage
// sendUDPBatch (sendmmsg) gives the regular UDP path.
//
// Wire format for N>1: [0xBA magic][2-byte BE len][packet]...[2-byte BE len][packet]
// Single packet: sent as-is with no framing overhead.
func (c *Conn) sendWebRTCBatch(pubKey key.NodePublic, buffs [][]byte, offset int) error {
if c.webrtcMgr == nil {
return nil
}
// Resolve endpoint and disco key once for the whole batch.
c.mu.Lock()
ep, ok := c.peerMap.endpointForNodeKey(pubKey)
c.mu.Unlock()
if !ok || ep == nil {
return nil
}
disco := ep.disco.Load()
if disco == nil {
return nil
}
if len(buffs) == 1 {
// Fast path: single packet, no framing overhead.
b := buffs[0][offset:]
if err := c.webrtcMgr.SendPacket(disco.key(), b); err != nil {
return err
}
c.metrics.outboundPacketsWebRTCTotal.Add(1)
c.metrics.outboundBytesWebRTCTotal.Add(int64(len(b)))
return nil
}
// Multi-packet batch path.
size := 1 // magic byte
for _, b := range buffs {
size += 2 + len(b[offset:])
}
batch := make([]byte, size)
batch[0] = WebRTCBatchMagic
pos := 1
var totalBytes int64
for _, b := range buffs {
pkt := b[offset:]
binary.BigEndian.PutUint16(batch[pos:], uint16(len(pkt)))
pos += 2
copy(batch[pos:], pkt)
pos += len(pkt)
totalBytes += int64(len(pkt))
}
if err := c.webrtcMgr.SendPacket(disco.key(), batch); err != nil {
return err
}
c.metrics.outboundPacketsWebRTCTotal.Add(int64(len(buffs)))
c.metrics.outboundBytesWebRTCTotal.Add(totalBytes)
return nil
}
// sendWebRTC sends a single packet over the WebRTC data channel to pubKey.
func (c *Conn) sendWebRTC(addr netip.AddrPort, pubKey key.NodePublic, b []byte) (sent bool, err error) {
if c.webrtcMgr == nil {
return false, nil
}
c.mu.Lock()
ep, ok := c.peerMap.endpointForNodeKey(pubKey)
c.mu.Unlock()
if !ok {
return false, nil
}
disco := ep.disco.Load()
if disco == nil {
return false, nil
}
if err := c.webrtcMgr.SendPacket(disco.key(), b); err != nil {
return false, err
}
c.metrics.outboundPacketsWebRTCTotal.Add(1)
c.metrics.outboundBytesWebRTCTotal.Add(int64(len(b)))
return true, nil
}
// receiveWebRTC is the [conn.ReceiveFunc] for the WebRTC path. It reads packets
// queued by [Conn.DeliverPacket] from webrtcRecvCh, blocking until one carrying
// WireGuard data is available, and returns that single packet.
func (c *connBind) receiveWebRTC(slab []byte, packets []conn.ReceivedPacket) (int, error) {
// Mirror receiveDERP: block for messages and keep waiting when a message
// yields no WireGuard data (n == 0, e.g. a disco packet handled inline).
// A ReceiveFunc must never return (0, nil): wireguard-go treats that as a
// spurious wake and it stops delivering, which manifests as one packet
// getting through and then a stall.
for wr := range c.webrtcRecvCh {
if c.isClosed() {
break
}
n, ep := c.processWebRTCReadResult(wr, slab)
if n == 0 {
// No WireGuard data (disco packet, unknown peer, etc.). Wait for
// the next one.
continue
}
packets[0].Size = n
packets[0].Offset = 0
packets[0].Endpoint = ep
return 1, nil
}
return 0, net.ErrClosed
}
// processWebRTCReadResult processes a WebRTC packet received from webrtcRecvCh.
// It's similar to processDERPReadResult but for WebRTC packets.
func (c *Conn) processWebRTCReadResult(wr webrtcReadResult, b []byte) (n int, ep *endpoint) {
if wr.buf == nil {
return 0, nil
}
n = wr.n
ncopy := copy(b, wr.buf[:n])
if ncopy != n {
err := fmt.Errorf("received WebRTC packet of length %d that's too big for WireGuard buf size %d", n, ncopy)
c.logf("magicsock: %v", err)
return 0, nil
}
srcAddr := epAddr{ap: netip.AddrPortFrom(tailcfg.WebRTCMagicIPAddr, webrtcMagicPort)}
// Check if this looks like a disco packet
pt, isGeneveEncap := packetLooksLike(b[:n])
if pt == packetLooksLikeDisco && !isGeneveEncap {
c.handleDiscoMessage(b[:n], srcAddr, false, wr.src, discoRXPathWebRTC)
return 0, nil
}
// Find the endpoint by node key
var ok bool
c.mu.Lock()
ep, ok = c.peerMap.endpointForNodeKey(wr.src)
c.mu.Unlock()
if !ok {
// We don't know anything about this node key
return 0, nil
}
ep.noteRecvActivity(srcAddr, mono.Now())
if update := c.connCounter.Load(); update != nil {
update(0, netip.AddrPortFrom(ep.nodeAddr, 0), srcAddr.ap, 1, n, true)
}
c.metrics.inboundPacketsWebRTCTotal.Add(1)
c.metrics.inboundBytesWebRTCTotal.Add(int64(n))
return n, ep
}
+167
View File
@@ -0,0 +1,167 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package magicsock
import (
"net/netip"
"testing"
"time"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
)
// TestWebRTCMagicIP tests the WebRTC magic IP constant.
func TestWebRTCMagicIP(t *testing.T) {
if tailcfg.WebRTCMagicIPAddr.String() != "127.3.3.41" {
t.Errorf("WebRTC magic IP = %v, want 127.3.3.41", tailcfg.WebRTCMagicIPAddr)
}
// Verify it's different from DERP magic IP
if tailcfg.WebRTCMagicIPAddr == tailcfg.DerpMagicIPAddr {
t.Error("WebRTC magic IP should be different from DERP magic IP")
}
}
// TestWebRTCPathPriority tests path preference logic: direct > WebRTC > DERP.
func TestWebRTCPathPriority(t *testing.T) {
directV4 := addrQuality{
epAddr: epAddr{ap: netip.MustParseAddrPort("192.168.1.100:41641")},
latency: 10 * time.Millisecond,
}
webrtc := addrQuality{
epAddr: epAddr{ap: netip.AddrPortFrom(tailcfg.WebRTCMagicIPAddr, webrtcMagicPort)},
latency: 50 * time.Millisecond,
}
derp := addrQuality{
epAddr: epAddr{ap: netip.AddrPortFrom(tailcfg.DerpMagicIPAddr, 1)},
latency: 100 * time.Millisecond,
}
tests := []struct {
name string
a, b addrQuality
want bool // true if a is better than b
}{
{"direct beats WebRTC", directV4, webrtc, true},
{"WebRTC beats DERP", webrtc, derp, true},
{"direct beats DERP", directV4, derp, true},
{"DERP loses to WebRTC", derp, webrtc, false},
{"WebRTC loses to direct", webrtc, directV4, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := betterAddr(tt.a, tt.b)
if got != tt.want {
t.Errorf("betterAddr(%v, %v) = %v, want %v", tt.a.ap, tt.b.ap, got, tt.want)
}
})
}
}
// TestWebRTCPathSelection tests iterated path selection with WebRTC in the mix.
func TestWebRTCPathSelection(t *testing.T) {
tests := []struct {
name string
paths []addrQuality
wantBest string
}{
{
name: "direct_beats_all",
paths: []addrQuality{
{epAddr: epAddr{ap: netip.MustParseAddrPort("1.2.3.4:1234")}},
{epAddr: epAddr{ap: netip.AddrPortFrom(tailcfg.WebRTCMagicIPAddr, webrtcMagicPort)}},
{epAddr: epAddr{ap: netip.AddrPortFrom(tailcfg.DerpMagicIPAddr, 1)}},
},
wantBest: "1.2.3.4:1234",
},
{
name: "webrtc_beats_derp",
paths: []addrQuality{
{epAddr: epAddr{ap: netip.AddrPortFrom(tailcfg.WebRTCMagicIPAddr, webrtcMagicPort)}},
{epAddr: epAddr{ap: netip.AddrPortFrom(tailcfg.DerpMagicIPAddr, 1)}},
},
wantBest: "127.3.3.41:12345",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
best := tt.paths[0]
for _, path := range tt.paths[1:] {
if betterAddr(path, best) {
best = path
}
}
if best.ap.String() != tt.wantBest {
t.Errorf("Best path = %v, want %v", best.ap, tt.wantBest)
}
})
}
}
// TestWebRTCReadResult tests the webrtcReadResult structure.
func TestWebRTCReadResult(t *testing.T) {
nodeKey := key.NewNode()
testData := []byte("test packet data")
result := webrtcReadResult{
n: len(testData),
src: nodeKey.Public(),
buf: testData,
}
if result.n != len(testData) {
t.Errorf("result.n = %d, want %d", result.n, len(testData))
}
if result.src != nodeKey.Public() {
t.Errorf("result.src mismatch")
}
if string(result.buf) != string(testData) {
t.Errorf("result.buf = %q, want %q", result.buf, testData)
}
}
// TestDiscoRXPathWebRTC tests the WebRTC disco path constant.
func TestDiscoRXPathWebRTC(t *testing.T) {
if discoRXPathWebRTC != "WebRTC" {
t.Errorf("discoRXPathWebRTC = %q, want %q", discoRXPathWebRTC, "WebRTC")
}
if discoRXPathWebRTC == discoRXPathDERP {
t.Error("WebRTC path should be different from DERP path")
}
if discoRXPathWebRTC == discoRXPathUDP {
t.Error("WebRTC path should be different from UDP path")
}
}
// TestWebRTCMetrics tests that WebRTC metrics are properly defined.
func TestWebRTCMetrics(t *testing.T) {
if metricRecvDataPacketsWebRTC == nil {
t.Error("metricRecvDataPacketsWebRTC should be initialized")
}
if metricRecvDataBytesWebRTC == nil {
t.Error("metricRecvDataBytesWebRTC should be initialized")
}
if metricSendDataPacketsWebRTC == nil {
t.Error("metricSendDataPacketsWebRTC should be initialized")
}
if metricSendDataBytesWebRTC == nil {
t.Error("metricSendDataBytesWebRTC should be initialized")
}
}
// TestPathWebRTCConstant tests the PathWebRTC constant.
func TestPathWebRTCConstant(t *testing.T) {
if PathWebRTC != "webrtc" {
t.Errorf("PathWebRTC = %q, want %q", PathWebRTC, "webrtc")
}
if PathWebRTC == PathDERP {
t.Error("PathWebRTC should be different from PathDERP")
}
if PathWebRTC == PathDirectIPv4 {
t.Error("PathWebRTC should be different from PathDirectIPv4")
}
}