mirror of https://github.com/tailscale/tailscale/
net/netcheck: try ICMP if UDP is blocked (#5056)
Signed-off-by: Andrew Dunham <andrew@du.nham.ca>pull/5301/head
parent
ddebd30917
commit
f0d6f173c9
@ -0,0 +1,253 @@
|
|||||||
|
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
// Package ping allows sending ICMP echo requests to a host in order to
|
||||||
|
// determine network latency.
|
||||||
|
package ping
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/net/icmp"
|
||||||
|
"golang.org/x/net/ipv4"
|
||||||
|
"tailscale.com/net/netns"
|
||||||
|
"tailscale.com/types/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type response struct {
|
||||||
|
t time.Time
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type outstanding struct {
|
||||||
|
ch chan response
|
||||||
|
data []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pinger represents a set of ICMP echo requests to be sent at a single time.
|
||||||
|
//
|
||||||
|
// A new instance should be created for each concurrent set of ping requests;
|
||||||
|
// this type should not be reused.
|
||||||
|
type Pinger struct {
|
||||||
|
c net.PacketConn
|
||||||
|
Logf logger.Logf
|
||||||
|
Verbose bool
|
||||||
|
timeNow func() time.Time
|
||||||
|
id uint16 // uint16 per RFC 792
|
||||||
|
wg sync.WaitGroup
|
||||||
|
|
||||||
|
// Following fields protected by mu
|
||||||
|
mu sync.Mutex
|
||||||
|
seq uint16 // uint16 per RFC 792
|
||||||
|
pings map[uint16]outstanding
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new Pinger. The Context provided will be used to create
|
||||||
|
// network listeners, and to set an absolute deadline (if any) on the net.Conn
|
||||||
|
func New(ctx context.Context, logf logger.Logf) (*Pinger, error) {
|
||||||
|
p, err := newUnstarted(ctx, logf)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start by setting the deadline from the context; note that this
|
||||||
|
// applies to all future I/O, so we only need to do it once.
|
||||||
|
deadline, ok := ctx.Deadline()
|
||||||
|
if ok {
|
||||||
|
if err := p.c.SetReadDeadline(deadline); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p.wg.Add(1)
|
||||||
|
go p.run(ctx)
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newUnstarted(ctx context.Context, logf logger.Logf) (*Pinger, error) {
|
||||||
|
var id [2]byte
|
||||||
|
_, err := rand.Read(id[:])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := netns.Listener(logf).ListenPacket(ctx, "ip4:icmp", "0.0.0.0")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Pinger{
|
||||||
|
c: conn,
|
||||||
|
Logf: logf,
|
||||||
|
timeNow: time.Now,
|
||||||
|
id: binary.LittleEndian.Uint16(id[:]),
|
||||||
|
pings: make(map[uint16]outstanding),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pinger) logf(format string, a ...any) {
|
||||||
|
if p.Logf != nil {
|
||||||
|
p.Logf(format, a...)
|
||||||
|
} else {
|
||||||
|
log.Printf(format, a...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pinger) vlogf(format string, a ...any) {
|
||||||
|
if p.Verbose {
|
||||||
|
p.logf(format, a...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pinger) Close() error {
|
||||||
|
err := p.c.Close()
|
||||||
|
p.wg.Wait()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pinger) run(ctx context.Context) {
|
||||||
|
defer p.wg.Done()
|
||||||
|
buf := make([]byte, 1500)
|
||||||
|
|
||||||
|
loop:
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
break loop
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
n, addr, err := p.c.ReadFrom(buf)
|
||||||
|
if err != nil {
|
||||||
|
// Ignore temporary errors; everything else is fatal
|
||||||
|
if netErr, ok := err.(net.Error); !ok || !netErr.Temporary() {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
p.handleResponse(buf[:n], addr, p.timeNow())
|
||||||
|
}
|
||||||
|
|
||||||
|
p.cleanupOutstanding()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pinger) cleanupOutstanding() {
|
||||||
|
// Complete outstanding requests
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
for _, o := range p.pings {
|
||||||
|
o.ch <- response{err: net.ErrClosed}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pinger) handleResponse(buf []byte, addr net.Addr, now time.Time) {
|
||||||
|
const ProtocolICMP = 1
|
||||||
|
m, err := icmp.ParseMessage(ProtocolICMP, buf)
|
||||||
|
if err != nil {
|
||||||
|
p.vlogf("handleResponse: invalid packet: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.Type != ipv4.ICMPTypeEchoReply {
|
||||||
|
p.vlogf("handleResponse: wanted m.Type=%d; got %d", ipv4.ICMPTypeEchoReply, m.Type)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, ok := m.Body.(*icmp.Echo)
|
||||||
|
if !ok || resp == nil {
|
||||||
|
p.vlogf("handleResponse: wanted body=*icmp.Echo; got %v", m.Body)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// We assume we sent this if the ID in the response is ours.
|
||||||
|
if uint16(resp.ID) != p.id {
|
||||||
|
p.vlogf("handleResponse: wanted ID=%d; got %d", p.id, resp.ID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for existing running echo request
|
||||||
|
var o outstanding
|
||||||
|
p.mu.Lock()
|
||||||
|
if o, ok = p.pings[uint16(resp.Seq)]; ok {
|
||||||
|
// Ensure that the data matches before we delete from our map,
|
||||||
|
// so a future correct packet will be handled correctly.
|
||||||
|
if bytes.Equal(resp.Data, o.data) {
|
||||||
|
delete(p.pings, uint16(resp.Seq))
|
||||||
|
} else {
|
||||||
|
p.vlogf("handleResponse: got response for Seq %d with mismatched data", resp.Seq)
|
||||||
|
ok = false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
p.vlogf("handleResponse: got response for unknown Seq %d", resp.Seq)
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
if ok {
|
||||||
|
o.ch <- response{t: now}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send sends an ICMP Echo Request packet to the destination, waits for a
|
||||||
|
// response, and returns the duration between when the request was sent and
|
||||||
|
// when the reply was received.
|
||||||
|
//
|
||||||
|
// If provided, "data" is sent with the packet and is compared upon receiving a
|
||||||
|
// reply.
|
||||||
|
func (p *Pinger) Send(ctx context.Context, dest net.Addr, data []byte) (time.Duration, error) {
|
||||||
|
// Use sequential sequence numbers on the assumption that we will not
|
||||||
|
// wrap around when using a single Pinger instance
|
||||||
|
p.mu.Lock()
|
||||||
|
p.seq++
|
||||||
|
seq := p.seq
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
m := icmp.Message{
|
||||||
|
Type: ipv4.ICMPTypeEcho,
|
||||||
|
Code: 0,
|
||||||
|
Body: &icmp.Echo{
|
||||||
|
ID: int(p.id),
|
||||||
|
Seq: int(seq),
|
||||||
|
Data: data,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
b, err := m.Marshal(nil)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register our response before sending since we could otherwise race a
|
||||||
|
// quick reply.
|
||||||
|
ch := make(chan response, 1)
|
||||||
|
p.mu.Lock()
|
||||||
|
p.pings[seq] = outstanding{ch: ch, data: data}
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
start := p.timeNow()
|
||||||
|
n, err := p.c.WriteTo(b, dest)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
} else if n != len(b) {
|
||||||
|
return 0, fmt.Errorf("conn.WriteTo: got %v; want %v", n, len(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case resp := <-ch:
|
||||||
|
if resp.err != nil {
|
||||||
|
return 0, resp.err
|
||||||
|
}
|
||||||
|
return resp.t.Sub(start), nil
|
||||||
|
|
||||||
|
case <-ctx.Done():
|
||||||
|
return 0, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,255 @@
|
|||||||
|
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package ping
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/net/icmp"
|
||||||
|
"golang.org/x/net/ipv4"
|
||||||
|
"tailscale.com/tstest"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
localhost = &net.IPAddr{IP: net.IPv4(127, 0, 0, 1)}
|
||||||
|
localhostUDP = &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 12345}
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPinger(t *testing.T) {
|
||||||
|
clock := &tstest.Clock{}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
p, closeP := mockPinger(t, clock)
|
||||||
|
defer closeP()
|
||||||
|
|
||||||
|
bodyData := []byte("data goes here")
|
||||||
|
|
||||||
|
// Start a ping in the background
|
||||||
|
r := make(chan time.Duration, 1)
|
||||||
|
go func() {
|
||||||
|
dur, err := p.Send(ctx, localhostUDP, bodyData)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("p.Send: %v", err)
|
||||||
|
r <- 0
|
||||||
|
} else {
|
||||||
|
r <- dur
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
p.waitOutstanding(t, ctx, 1)
|
||||||
|
|
||||||
|
// Fake a response from ourself
|
||||||
|
fakeResponse := mustMarshal(t, &icmp.Message{
|
||||||
|
Type: ipv4.ICMPTypeEchoReply,
|
||||||
|
Code: 0,
|
||||||
|
Body: &icmp.Echo{
|
||||||
|
ID: 1234,
|
||||||
|
Seq: 1,
|
||||||
|
Data: bodyData,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const fakeDuration = 100 * time.Millisecond
|
||||||
|
p.handleResponse(fakeResponse, localhost, clock.Now().Add(fakeDuration))
|
||||||
|
|
||||||
|
select {
|
||||||
|
case dur := <-r:
|
||||||
|
want := fakeDuration
|
||||||
|
if dur != want {
|
||||||
|
t.Errorf("wanted ping response time = %d; got %d", want, dur)
|
||||||
|
}
|
||||||
|
case <-ctx.Done():
|
||||||
|
t.Fatal("did not get response by timeout")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPingerTimeout(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
clock := &tstest.Clock{}
|
||||||
|
p, closeP := mockPinger(t, clock)
|
||||||
|
defer closeP()
|
||||||
|
|
||||||
|
// Send a ping in the background
|
||||||
|
r := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, err := p.Send(ctx, localhostUDP, []byte("data goes here"))
|
||||||
|
r <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait until we're blocking
|
||||||
|
p.waitOutstanding(t, ctx, 1)
|
||||||
|
|
||||||
|
// Close everything down
|
||||||
|
p.cleanupOutstanding()
|
||||||
|
|
||||||
|
// Should have got an error from the ping
|
||||||
|
err := <-r
|
||||||
|
if !errors.Is(err, net.ErrClosed) {
|
||||||
|
t.Errorf("wanted errors.Is(err, net.ErrClosed); got=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPingerMismatch(t *testing.T) {
|
||||||
|
clock := &tstest.Clock{}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 1*time.Second) // intentionally short
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
p, closeP := mockPinger(t, clock)
|
||||||
|
defer closeP()
|
||||||
|
|
||||||
|
bodyData := []byte("data goes here")
|
||||||
|
|
||||||
|
// Start a ping in the background
|
||||||
|
r := make(chan time.Duration, 1)
|
||||||
|
go func() {
|
||||||
|
dur, err := p.Send(ctx, localhostUDP, bodyData)
|
||||||
|
if err != nil && !errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
t.Errorf("p.Send: %v", err)
|
||||||
|
r <- 0
|
||||||
|
} else {
|
||||||
|
r <- dur
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
p.waitOutstanding(t, ctx, 1)
|
||||||
|
|
||||||
|
// "Receive" a bunch of intentionally malformed packets that should not
|
||||||
|
// result in the Send call above returning
|
||||||
|
badPackets := []struct {
|
||||||
|
name string
|
||||||
|
pkt *icmp.Message
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "wrong type",
|
||||||
|
pkt: &icmp.Message{
|
||||||
|
Type: ipv4.ICMPTypeDestinationUnreachable,
|
||||||
|
Code: 0,
|
||||||
|
Body: &icmp.DstUnreach{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrong id",
|
||||||
|
pkt: &icmp.Message{
|
||||||
|
Type: ipv4.ICMPTypeEchoReply,
|
||||||
|
Code: 0,
|
||||||
|
Body: &icmp.Echo{
|
||||||
|
ID: 9999,
|
||||||
|
Seq: 1,
|
||||||
|
Data: bodyData,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrong seq",
|
||||||
|
pkt: &icmp.Message{
|
||||||
|
Type: ipv4.ICMPTypeEchoReply,
|
||||||
|
Code: 0,
|
||||||
|
Body: &icmp.Echo{
|
||||||
|
ID: 1234,
|
||||||
|
Seq: 5,
|
||||||
|
Data: bodyData,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bad body",
|
||||||
|
pkt: &icmp.Message{
|
||||||
|
Type: ipv4.ICMPTypeEchoReply,
|
||||||
|
Code: 0,
|
||||||
|
Body: &icmp.Echo{
|
||||||
|
ID: 1234,
|
||||||
|
Seq: 1,
|
||||||
|
|
||||||
|
// Intentionally missing first byte
|
||||||
|
Data: bodyData[1:],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const fakeDuration = 100 * time.Millisecond
|
||||||
|
tm := clock.Now().Add(fakeDuration)
|
||||||
|
|
||||||
|
for _, tt := range badPackets {
|
||||||
|
fakeResponse := mustMarshal(t, tt.pkt)
|
||||||
|
p.handleResponse(fakeResponse, localhost, tm)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also "receive" a packet that does not unmarshal as an ICMP packet
|
||||||
|
p.handleResponse([]byte("foo"), localhost, tm)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-r:
|
||||||
|
t.Fatal("wanted timeout")
|
||||||
|
case <-ctx.Done():
|
||||||
|
t.Logf("test correctly timed out")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mockPinger(t *testing.T, clock *tstest.Clock) (*Pinger, func()) {
|
||||||
|
// In tests, we use UDP so that we can test without being root; this
|
||||||
|
// doesn't matter becuase we mock out the ICMP reply below to be a real
|
||||||
|
// ICMP echo reply packet.
|
||||||
|
conn, err := net.ListenPacket("udp4", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("net.ListenPacket: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &Pinger{
|
||||||
|
c: conn,
|
||||||
|
Logf: t.Logf,
|
||||||
|
Verbose: true,
|
||||||
|
timeNow: clock.Now,
|
||||||
|
id: 1234,
|
||||||
|
pings: make(map[uint16]outstanding),
|
||||||
|
}
|
||||||
|
done := func() {
|
||||||
|
if err := p.Close(); err != nil {
|
||||||
|
t.Errorf("error on close: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p, done
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustMarshal(t *testing.T, m *icmp.Message) []byte {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
b, err := m.Marshal(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pinger) waitOutstanding(t *testing.T, ctx context.Context, count int) {
|
||||||
|
// This is a bit janky, but... we busy-loop to wait for the Send call
|
||||||
|
// to write to our map so we know that a response will be handled.
|
||||||
|
var haveMapEntry bool
|
||||||
|
for !haveMapEntry {
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
t.Error("no entry in ping map before timeout")
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
haveMapEntry = len(p.pings) == count
|
||||||
|
p.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue