mirror of
https://github.com/qdm12/gluetun.git
synced 2025-12-11 04:38:54 -06:00
194 lines
5.4 KiB
Go
194 lines
5.4 KiB
Go
package icmp
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
cryptorand "crypto/rand"
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math/rand/v2"
|
|
"net"
|
|
"net/netip"
|
|
"strings"
|
|
|
|
"golang.org/x/net/icmp"
|
|
"golang.org/x/net/ipv4"
|
|
"golang.org/x/net/ipv6"
|
|
)
|
|
|
|
var (
|
|
ErrICMPBodyUnsupported = errors.New("ICMP body type is not supported")
|
|
ErrICMPEchoDataMismatch = errors.New("ICMP data mismatch")
|
|
)
|
|
|
|
type Echoer struct {
|
|
buffer []byte
|
|
randomSource io.Reader
|
|
logger Logger
|
|
}
|
|
|
|
func NewEchoer(logger Logger) *Echoer {
|
|
const maxICMPEchoSize = 1500
|
|
buffer := make([]byte, maxICMPEchoSize)
|
|
var seed [32]byte
|
|
_, _ = cryptorand.Read(seed[:])
|
|
randomSource := rand.NewChaCha8(seed)
|
|
return &Echoer{
|
|
buffer: buffer,
|
|
randomSource: randomSource,
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
var (
|
|
ErrTimedOut = errors.New("timed out waiting for ICMP echo reply")
|
|
ErrNotPermitted = errors.New("not permitted")
|
|
)
|
|
|
|
func (i *Echoer) Echo(ctx context.Context, ip netip.Addr) (err error) {
|
|
var ipVersion string
|
|
var conn net.PacketConn
|
|
if ip.Is4() {
|
|
ipVersion = "v4"
|
|
conn, err = listenICMPv4(ctx)
|
|
} else {
|
|
ipVersion = "v6"
|
|
conn, err = listenICMPv6(ctx)
|
|
}
|
|
if err != nil {
|
|
if strings.HasSuffix(err.Error(), "socket: operation not permitted") {
|
|
err = fmt.Errorf("%w: you can try adding NET_RAW capability to resolve this", ErrNotPermitted)
|
|
}
|
|
return fmt.Errorf("listening for ICMP packets: %w", err)
|
|
}
|
|
|
|
go func() {
|
|
<-ctx.Done()
|
|
conn.Close()
|
|
}()
|
|
|
|
const echoDataSize = 32
|
|
id, message := buildMessageToSend(ipVersion, echoDataSize, i.randomSource)
|
|
|
|
encodedMessage, err := message.Marshal(nil)
|
|
if err != nil {
|
|
return fmt.Errorf("encoding ICMP message: %w", err)
|
|
}
|
|
|
|
_, err = conn.WriteTo(encodedMessage, &net.IPAddr{IP: ip.AsSlice()})
|
|
if err != nil {
|
|
if strings.HasSuffix(err.Error(), "sendto: operation not permitted") {
|
|
err = fmt.Errorf("%w", ErrNotPermitted)
|
|
}
|
|
return fmt.Errorf("writing ICMP message to %s: %w", ip, err)
|
|
}
|
|
|
|
receivedData, err := receiveEchoReply(conn, id, i.buffer, ipVersion, i.logger)
|
|
if err != nil {
|
|
if errors.Is(err, net.ErrClosed) && ctx.Err() != nil {
|
|
return fmt.Errorf("%w from %s", ErrTimedOut, ip)
|
|
}
|
|
return fmt.Errorf("receiving ICMP echo reply from %s: %w", ip, err)
|
|
}
|
|
|
|
sentData := message.Body.(*icmp.Echo).Data //nolint:forcetypeassert
|
|
if !bytes.Equal(receivedData, sentData) {
|
|
return fmt.Errorf("%w: sent %x to %s and received %x", ErrICMPEchoDataMismatch, sentData, ip, receivedData)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func buildMessageToSend(ipVersion string, size uint, randomSource io.Reader) (id int, message *icmp.Message) {
|
|
const uint16Bytes = 2
|
|
idBytes := make([]byte, uint16Bytes)
|
|
_, _ = randomSource.Read(idBytes)
|
|
id = int(binary.BigEndian.Uint16(idBytes))
|
|
|
|
var icmpType icmp.Type
|
|
switch ipVersion {
|
|
case "v4":
|
|
icmpType = ipv4.ICMPTypeEcho
|
|
case "v6":
|
|
icmpType = ipv6.ICMPTypeEchoRequest
|
|
default:
|
|
panic(fmt.Sprintf("IP version %q not supported", ipVersion))
|
|
}
|
|
messageBodyData := make([]byte, size)
|
|
_, _ = randomSource.Read(messageBodyData)
|
|
|
|
// See https://www.iana.org/assignments/icmp-parameters/icmp-parameters.xhtml#icmp-parameters-types
|
|
message = &icmp.Message{
|
|
Type: icmpType, // echo request
|
|
Code: 0, // no code
|
|
Checksum: 0, // calculated at encoding (ipv4) or sending (ipv6)
|
|
Body: &icmp.Echo{
|
|
ID: id,
|
|
Seq: 0, // only one packet
|
|
Data: messageBodyData,
|
|
},
|
|
}
|
|
return id, message
|
|
}
|
|
|
|
func receiveEchoReply(conn net.PacketConn, id int, buffer []byte, ipVersion string, logger Logger,
|
|
) (data []byte, err error) {
|
|
var icmpProtocol int
|
|
const (
|
|
icmpv4Protocol = 1
|
|
icmpv6Protocol = 58
|
|
)
|
|
switch ipVersion {
|
|
case "v4":
|
|
icmpProtocol = icmpv4Protocol
|
|
case "v6":
|
|
icmpProtocol = icmpv6Protocol
|
|
default:
|
|
panic(fmt.Sprintf("unknown IP version: %s", ipVersion))
|
|
}
|
|
|
|
for {
|
|
// Note we need to read the whole packet in one call to ReadFrom, so the buffer
|
|
// must be large enough to read the entire reply packet. See:
|
|
// https://groups.google.com/g/golang-nuts/c/5dy2Q4nPs08/m/KmuSQAGEtG4J
|
|
bytesRead, returnAddr, err := conn.ReadFrom(buffer)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading from ICMP connection: %w", err)
|
|
}
|
|
packetBytes := buffer[:bytesRead]
|
|
|
|
// Parse the ICMP message
|
|
message, err := icmp.ParseMessage(icmpProtocol, packetBytes)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parsing message: %w", err)
|
|
}
|
|
|
|
switch body := message.Body.(type) {
|
|
case *icmp.Echo:
|
|
if id != body.ID {
|
|
logger.Warnf("ignoring ICMP echo reply mismatching expected id %d "+
|
|
"(id: %d, type: %d, code: %d, length: %d, return address %s)",
|
|
id, body.ID, message.Type, message.Code, len(packetBytes), returnAddr)
|
|
continue // not the ID we are looking for
|
|
}
|
|
return body.Data, nil
|
|
case *icmp.DstUnreach:
|
|
logger.Debugf("ignoring ICMP destination unreachable message (type: 3, code: %d, return address %s, expected-id %d)",
|
|
message.Code, returnAddr, id)
|
|
// See https://github.com/qdm12/gluetun/pull/2923#issuecomment-3377532249
|
|
// on why we ignore this message. If it is actually unreachable, the timeout on waiting for
|
|
// the echo reply will do instead of returning an error error.
|
|
continue
|
|
case *icmp.TimeExceeded:
|
|
logger.Debugf("ignoring ICMP time exceeded message (type: 11, code: %d, return address %s, expected-id %d)",
|
|
message.Code, returnAddr, id)
|
|
continue
|
|
default:
|
|
return nil, fmt.Errorf("%w: %T (type %d, code %d, return address %s, expected-id %d)",
|
|
ErrICMPBodyUnsupported, body, message.Type, message.Code, returnAddr, id)
|
|
}
|
|
}
|
|
}
|