feature/capture: move packet capture to feature/*, out of iOS + CLI

We had the debug packet capture code + Lua dissector in the CLI + the
iOS app. Now we don't, with tests to lock it in.

As a bonus, tailscale.com/net/packet and tailscale.com/net/flowtrack
no longer appear in the CLI's binary either.

A new build tag ts_omit_capture disables the packet capture code and
was added to build_dist.sh's --extra-small mode.

Updates #12614

Change-Id: I79b0628c0d59911bd4d510c732284d97b0160f10
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
Brad Fitzpatrick
2025-01-23 20:39:28 -08:00
committed by Brad Fitzpatrick
parent 2c98c44d9a
commit 68a66ee81b
23 changed files with 620 additions and 484 deletions

View File

@@ -1,238 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package capture formats packet logging into a debug pcap stream.
package capture
import (
"bytes"
"context"
"encoding/binary"
"io"
"net/http"
"sync"
"time"
_ "embed"
"tailscale.com/net/packet"
"tailscale.com/util/set"
)
//go:embed ts-dissector.lua
var DissectorLua string
// Callback describes a function which is called to
// record packets when debugging packet-capture.
// Such callbacks must not take ownership of the
// provided data slice: it may only copy out of it
// within the lifetime of the function.
type Callback func(Path, time.Time, []byte, packet.CaptureMeta)
var bufferPool = sync.Pool{
New: func() any {
return new(bytes.Buffer)
},
}
const flushPeriod = 100 * time.Millisecond
func writePcapHeader(w io.Writer) {
binary.Write(w, binary.LittleEndian, uint32(0xA1B2C3D4)) // pcap magic number
binary.Write(w, binary.LittleEndian, uint16(2)) // version major
binary.Write(w, binary.LittleEndian, uint16(4)) // version minor
binary.Write(w, binary.LittleEndian, uint32(0)) // this zone
binary.Write(w, binary.LittleEndian, uint32(0)) // zone significant figures
binary.Write(w, binary.LittleEndian, uint32(65535)) // max packet len
binary.Write(w, binary.LittleEndian, uint32(147)) // link-layer ID - USER0
}
func writePktHeader(w *bytes.Buffer, when time.Time, length int) {
s := when.Unix()
us := when.UnixMicro() - (s * 1000000)
binary.Write(w, binary.LittleEndian, uint32(s)) // timestamp in seconds
binary.Write(w, binary.LittleEndian, uint32(us)) // timestamp microseconds
binary.Write(w, binary.LittleEndian, uint32(length)) // length present
binary.Write(w, binary.LittleEndian, uint32(length)) // total length
}
// Path describes where in the data path the packet was captured.
type Path uint8
// Valid Path values.
const (
// FromLocal indicates the packet was logged as it traversed the FromLocal path:
// i.e.: A packet from the local system into the TUN.
FromLocal Path = 0
// FromPeer indicates the packet was logged upon reception from a remote peer.
FromPeer Path = 1
// SynthesizedToLocal indicates the packet was generated from within tailscaled,
// and is being routed to the local machine's network stack.
SynthesizedToLocal Path = 2
// SynthesizedToPeer indicates the packet was generated from within tailscaled,
// and is being routed to a remote Wireguard peer.
SynthesizedToPeer Path = 3
// PathDisco indicates the packet is information about a disco frame.
PathDisco Path = 254
)
// New creates a new capture sink.
func New() *Sink {
ctx, c := context.WithCancel(context.Background())
return &Sink{
ctx: ctx,
ctxCancel: c,
}
}
// Type Sink handles callbacks with packets to be logged,
// formatting them into a pcap stream which is mirrored to
// all registered outputs.
type Sink struct {
ctx context.Context
ctxCancel context.CancelFunc
mu sync.Mutex
outputs set.HandleSet[io.Writer]
flushTimer *time.Timer // or nil if none running
}
// RegisterOutput connects an output to this sink, which
// will be written to with a pcap stream as packets are logged.
// A function is returned which unregisters the output when
// called.
//
// If w implements io.Closer, it will be closed upon error
// or when the sink is closed. If w implements http.Flusher,
// it will be flushed periodically.
func (s *Sink) RegisterOutput(w io.Writer) (unregister func()) {
select {
case <-s.ctx.Done():
return func() {}
default:
}
writePcapHeader(w)
s.mu.Lock()
hnd := s.outputs.Add(w)
s.mu.Unlock()
return func() {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.outputs, hnd)
}
}
// NumOutputs returns the number of outputs registered with the sink.
func (s *Sink) NumOutputs() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.outputs)
}
// Close shuts down the sink. Future calls to LogPacket
// are ignored, and any registered output that implements
// io.Closer is closed.
func (s *Sink) Close() error {
s.ctxCancel()
s.mu.Lock()
defer s.mu.Unlock()
if s.flushTimer != nil {
s.flushTimer.Stop()
s.flushTimer = nil
}
for _, o := range s.outputs {
if o, ok := o.(io.Closer); ok {
o.Close()
}
}
s.outputs = nil
return nil
}
// WaitCh returns a channel which blocks until
// the sink is closed.
func (s *Sink) WaitCh() <-chan struct{} {
return s.ctx.Done()
}
func customDataLen(meta packet.CaptureMeta) int {
length := 4
if meta.DidSNAT {
length += meta.OriginalSrc.Addr().BitLen() / 8
}
if meta.DidDNAT {
length += meta.OriginalDst.Addr().BitLen() / 8
}
return length
}
// LogPacket is called to insert a packet into the capture.
//
// This function does not take ownership of the provided data slice.
func (s *Sink) LogPacket(path Path, when time.Time, data []byte, meta packet.CaptureMeta) {
select {
case <-s.ctx.Done():
return
default:
}
extraLen := customDataLen(meta)
b := bufferPool.Get().(*bytes.Buffer)
b.Reset()
b.Grow(16 + extraLen + len(data)) // 16b pcap header + len(metadata) + len(payload)
defer bufferPool.Put(b)
writePktHeader(b, when, len(data)+extraLen)
// Custom tailscale debugging data
binary.Write(b, binary.LittleEndian, uint16(path))
if meta.DidSNAT {
binary.Write(b, binary.LittleEndian, uint8(meta.OriginalSrc.Addr().BitLen()/8))
b.Write(meta.OriginalSrc.Addr().AsSlice())
} else {
binary.Write(b, binary.LittleEndian, uint8(0)) // SNAT addr len == 0
}
if meta.DidDNAT {
binary.Write(b, binary.LittleEndian, uint8(meta.OriginalDst.Addr().BitLen()/8))
b.Write(meta.OriginalDst.Addr().AsSlice())
} else {
binary.Write(b, binary.LittleEndian, uint8(0)) // DNAT addr len == 0
}
b.Write(data)
s.mu.Lock()
defer s.mu.Unlock()
var hadError []set.Handle
for hnd, o := range s.outputs {
if _, err := o.Write(b.Bytes()); err != nil {
hadError = append(hadError, hnd)
continue
}
}
for _, hnd := range hadError {
if o, ok := s.outputs[hnd].(io.Closer); ok {
o.Close()
}
delete(s.outputs, hnd)
}
if s.flushTimer == nil {
s.flushTimer = time.AfterFunc(flushPeriod, func() {
s.mu.Lock()
defer s.mu.Unlock()
for _, o := range s.outputs {
if f, ok := o.(http.Flusher); ok {
f.Flush()
}
}
s.flushTimer = nil
})
}
}

View File

@@ -1,169 +0,0 @@
function hasbit(x, p)
return x % (p + p) >= p
end
tsdebug_ll = Proto("tsdebug", "Tailscale debug")
PATH = ProtoField.string("tsdebug.PATH","PATH", base.ASCII)
SNAT_IP_4 = ProtoField.ipv4("tsdebug.SNAT_IP_4", "Pre-NAT Source IPv4 address")
SNAT_IP_6 = ProtoField.ipv6("tsdebug.SNAT_IP_6", "Pre-NAT Source IPv6 address")
DNAT_IP_4 = ProtoField.ipv4("tsdebug.DNAT_IP_4", "Pre-NAT Dest IPv4 address")
DNAT_IP_6 = ProtoField.ipv6("tsdebug.DNAT_IP_6", "Pre-NAT Dest IPv6 address")
tsdebug_ll.fields = {PATH, SNAT_IP_4, SNAT_IP_6, DNAT_IP_4, DNAT_IP_6}
function tsdebug_ll.dissector(buffer, pinfo, tree)
pinfo.cols.protocol = tsdebug_ll.name
packet_length = buffer:len()
local offset = 0
local subtree = tree:add(tsdebug_ll, buffer(), "Tailscale packet")
-- -- Get path UINT16
local path_id = buffer:range(offset, 2):le_uint()
if path_id == 0 then subtree:add(PATH, "FromLocal")
elseif path_id == 1 then subtree:add(PATH, "FromPeer")
elseif path_id == 2 then subtree:add(PATH, "Synthesized (Inbound / ToLocal)")
elseif path_id == 3 then subtree:add(PATH, "Synthesized (Outbound / ToPeer)")
elseif path_id == 254 then subtree:add(PATH, "Disco frame")
end
offset = offset + 2
-- -- Get SNAT address
local snat_addr_len = buffer:range(offset, 1):le_uint()
if snat_addr_len == 4 then subtree:add(SNAT_IP_4, buffer:range(offset + 1, snat_addr_len))
elseif snat_addr_len > 0 then subtree:add(SNAT_IP_6, buffer:range(offset + 1, snat_addr_len))
end
offset = offset + 1 + snat_addr_len
-- -- Get DNAT address
local dnat_addr_len = buffer:range(offset, 1):le_uint()
if dnat_addr_len == 4 then subtree:add(DNAT_IP_4, buffer:range(offset + 1, dnat_addr_len))
elseif dnat_addr_len > 0 then subtree:add(DNAT_IP_6, buffer:range(offset + 1, dnat_addr_len))
end
offset = offset + 1 + dnat_addr_len
-- -- Handover rest of data to lower-level dissector
local data_buffer = buffer:range(offset, packet_length-offset):tvb()
if path_id == 254 then
Dissector.get("tsdisco"):call(data_buffer, pinfo, tree)
else
Dissector.get("ip"):call(data_buffer, pinfo, tree)
end
end
-- Install the dissector on link-layer ID 147 (User-defined protocol 0)
local eth_table = DissectorTable.get("wtap_encap")
eth_table:add(wtap.USER0, tsdebug_ll)
local ts_dissectors = DissectorTable.new("ts.proto", "Tailscale-specific dissectors", ftypes.STRING, base.NONE)
--
-- DISCO metadata dissector
--
tsdisco_meta = Proto("tsdisco", "Tailscale DISCO metadata")
DISCO_IS_DERP = ProtoField.bool("tsdisco.IS_DERP","From DERP")
DISCO_SRC_IP_4 = ProtoField.ipv4("tsdisco.SRC_IP_4", "Source IPv4 address")
DISCO_SRC_IP_6 = ProtoField.ipv6("tsdisco.SRC_IP_6", "Source IPv6 address")
DISCO_SRC_PORT = ProtoField.uint16("tsdisco.SRC_PORT","Source port", base.DEC)
DISCO_DERP_PUB = ProtoField.bytes("tsdisco.DERP_PUB", "DERP public key", base.SPACE)
tsdisco_meta.fields = {DISCO_IS_DERP, DISCO_SRC_PORT, DISCO_DERP_PUB, DISCO_SRC_IP_4, DISCO_SRC_IP_6}
function tsdisco_meta.dissector(buffer, pinfo, tree)
pinfo.cols.protocol = tsdisco_meta.name
packet_length = buffer:len()
local offset = 0
local subtree = tree:add(tsdisco_meta, buffer(), "DISCO metadata")
-- Parse flags
local from_derp = hasbit(buffer(offset, 1):le_uint(), 0)
subtree:add(DISCO_IS_DERP, from_derp) -- Flag bit 0
offset = offset + 1
-- Parse DERP public key
if from_derp then
subtree:add(DISCO_DERP_PUB, buffer(offset, 32))
end
offset = offset + 32
-- Parse source port
subtree:add(DISCO_SRC_PORT, buffer:range(offset, 2):le_uint())
offset = offset + 2
-- Parse source address
local addr_len = buffer:range(offset, 2):le_uint()
offset = offset + 2
if addr_len == 4 then subtree:add(DISCO_SRC_IP_4, buffer:range(offset, addr_len))
else subtree:add(DISCO_SRC_IP_6, buffer:range(offset, addr_len))
end
offset = offset + addr_len
-- Handover to the actual disco frame dissector
offset = offset + 2 -- skip over payload len
local data_buffer = buffer:range(offset, packet_length-offset):tvb()
Dissector.get("disco"):call(data_buffer, pinfo, tree)
end
ts_dissectors:add(1, tsdisco_meta)
--
-- DISCO frame dissector
--
tsdisco_frame = Proto("disco", "Tailscale DISCO frame")
DISCO_TYPE = ProtoField.string("disco.TYPE", "Message type", base.ASCII)
DISCO_VERSION = ProtoField.uint8("disco.VERSION","Protocol version", base.DEC)
DISCO_TXID = ProtoField.bytes("disco.TXID", "Transaction ID", base.SPACE)
DISCO_NODEKEY = ProtoField.bytes("disco.NODE_KEY", "Node key", base.SPACE)
DISCO_PONG_SRC = ProtoField.ipv6("disco.PONG_SRC", "Pong source")
DISCO_PONG_SRC_PORT = ProtoField.uint16("disco.PONG_SRC_PORT","Source port", base.DEC)
DISCO_UNKNOWN = ProtoField.bytes("disco.UNKNOWN_DATA", "Trailing data", base.SPACE)
tsdisco_frame.fields = {DISCO_TYPE, DISCO_VERSION, DISCO_TXID, DISCO_NODEKEY, DISCO_PONG_SRC, DISCO_PONG_SRC_PORT, DISCO_UNKNOWN}
function tsdisco_frame.dissector(buffer, pinfo, tree)
packet_length = buffer:len()
local offset = 0
local subtree = tree:add(tsdisco_frame, buffer(), "DISCO frame")
-- Message type
local message_type = buffer(offset, 1):le_uint()
offset = offset + 1
if message_type == 1 then subtree:add(DISCO_TYPE, "Ping")
elseif message_type == 2 then subtree:add(DISCO_TYPE, "Pong")
elseif message_type == 3 then subtree:add(DISCO_TYPE, "Call me maybe")
end
-- Message version
local message_version = buffer(offset, 1):le_uint()
offset = offset + 1
subtree:add(DISCO_VERSION, message_version)
-- TXID (Ping / Pong)
if message_type == 1 or message_type == 2 then
subtree:add(DISCO_TXID, buffer(offset, 12))
offset = offset + 12
end
-- NodeKey (Ping)
if message_type == 1 then
subtree:add(DISCO_NODEKEY, buffer(offset, 32))
offset = offset + 32
end
-- Src (Pong)
if message_type == 2 then
subtree:add(DISCO_PONG_SRC, buffer:range(offset, 16))
offset = offset + 16
end
-- Src port (Pong)
if message_type == 2 then
subtree:add(DISCO_PONG_SRC_PORT, buffer(offset, 2):le_uint())
offset = offset + 2
end
-- TODO(tom): Parse CallMeMaybe.MyNumber
local trailing = buffer:range(offset, packet_length-offset)
if trailing:len() > 0 then
subtree:add(DISCO_UNKNOWN, trailing)
end
end
ts_dissectors:add(2, tsdisco_frame)

View File

@@ -61,7 +61,6 @@ import (
"tailscale.com/util/set"
"tailscale.com/util/testenv"
"tailscale.com/util/usermetric"
"tailscale.com/wgengine/capture"
"tailscale.com/wgengine/wgint"
)
@@ -238,7 +237,7 @@ type Conn struct {
stats atomic.Pointer[connstats.Statistics]
// captureHook, if non-nil, is the pcap logging callback when capturing.
captureHook syncs.AtomicValue[capture.Callback]
captureHook syncs.AtomicValue[packet.CaptureCallback]
// discoPrivate is the private naclbox key used for active
// discovery traffic. It is always present, and immutable.
@@ -655,7 +654,7 @@ func deregisterMetrics(m *metrics) {
// log debug information into the pcap stream. This function
// can be called with a nil argument to uninstall the capture
// hook.
func (c *Conn) InstallCaptureHook(cb capture.Callback) {
func (c *Conn) InstallCaptureHook(cb packet.CaptureCallback) {
c.captureHook.Store(cb)
}
@@ -1709,7 +1708,7 @@ func (c *Conn) handleDiscoMessage(msg []byte, src netip.AddrPort, derpNodeSrc ke
// Emit information about the disco frame into the pcap stream
// if a capture hook is installed.
if cb := c.captureHook.Load(); cb != nil {
cb(capture.PathDisco, time.Now(), disco.ToPCAPFrame(src, derpNodeSrc, payload), packet.CaptureMeta{})
cb(packet.PathDisco, time.Now(), disco.ToPCAPFrame(src, derpNodeSrc, payload), packet.CaptureMeta{})
}
dm, err := disco.Parse(payload)

View File

@@ -51,7 +51,6 @@ import (
"tailscale.com/util/testenv"
"tailscale.com/util/usermetric"
"tailscale.com/version"
"tailscale.com/wgengine/capture"
"tailscale.com/wgengine/filter"
"tailscale.com/wgengine/magicsock"
"tailscale.com/wgengine/netlog"
@@ -1594,7 +1593,7 @@ var (
metricNumMinorChanges = clientmetric.NewCounter("wgengine_minor_changes")
)
func (e *userspaceEngine) InstallCaptureHook(cb capture.Callback) {
func (e *userspaceEngine) InstallCaptureHook(cb packet.CaptureCallback) {
e.tundev.InstallCaptureHook(cb)
e.magicConn.InstallCaptureHook(cb)
}

View File

@@ -17,10 +17,10 @@ import (
"tailscale.com/envknob"
"tailscale.com/ipn/ipnstate"
"tailscale.com/net/dns"
"tailscale.com/net/packet"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
"tailscale.com/types/netmap"
"tailscale.com/wgengine/capture"
"tailscale.com/wgengine/filter"
"tailscale.com/wgengine/router"
"tailscale.com/wgengine/wgcfg"
@@ -162,7 +162,7 @@ func (e *watchdogEngine) Done() <-chan struct{} {
return e.wrap.Done()
}
func (e *watchdogEngine) InstallCaptureHook(cb capture.Callback) {
func (e *watchdogEngine) InstallCaptureHook(cb packet.CaptureCallback) {
e.wrap.InstallCaptureHook(cb)
}

View File

@@ -11,10 +11,10 @@ import (
"tailscale.com/ipn/ipnstate"
"tailscale.com/net/dns"
"tailscale.com/net/packet"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
"tailscale.com/types/netmap"
"tailscale.com/wgengine/capture"
"tailscale.com/wgengine/filter"
"tailscale.com/wgengine/router"
"tailscale.com/wgengine/wgcfg"
@@ -129,5 +129,5 @@ type Engine interface {
// InstallCaptureHook registers a function to be called to capture
// packets traversing the data path. The hook can be uninstalled by
// calling this function with a nil value.
InstallCaptureHook(capture.Callback)
InstallCaptureHook(packet.CaptureCallback)
}