net/netcheck: try ICMP if UDP is blocked (#5056)
Signed-off-by: Andrew Dunham <andrew@du.nham.ca>main
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