mirror of
https://github.com/tailscale/tailscale.git
synced 2026-10-23 03:37:19 +09:00
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:
1 parent
47743a8b9a
commit
98f7c4241e
27 files changed
+2238
-33
No files matched your search
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -173,4 +173,4 @@
|
||||
});
|
||||
};
|
||||
}
|
||||
# nix-direnv cache busting line: sha256-jrznfL26qyAGix9DVkmh0sLU2VvqeTNMdi6mPu9RlxM=
|
||||
# nix-direnv cache busting line: sha256-n6gM53lsiy+0W/6hZsAZO5vHT0QhO2Xlw0n4Tg5xf3k=
|
||||
+2
-2
@@ -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="
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,4 +16,4 @@
|
||||
) {
|
||||
src = ./.;
|
||||
}).shellNix
|
||||
# nix-direnv cache busting line: sha256-jrznfL26qyAGix9DVkmh0sLU2VvqeTNMdi6mPu9RlxM=
|
||||
# nix-direnv cache busting line: sha256-n6gM53lsiy+0W/6hZsAZO5vHT0QhO2Xlw0n4Tg5xf3k=
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
)
|
||||
|
||||
@@ -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
@@ -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()))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user