// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause

// 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"
	"errors"
	"fmt"
	"io"
	"log"
	"net"
	"net/netip"
	"sync"
	"sync/atomic"
	"time"

	"github.com/metacubex/tailscale/net/netx"
	"github.com/metacubex/tailscale/syncs"
	"github.com/metacubex/tailscale/types/logger"
	"github.com/metacubex/tailscale/util/mak"
	"golang.org/x/net/icmp"
	"golang.org/x/net/ipv4"
	"golang.org/x/net/ipv6"
)

const (
	v4Type    = "ip4:icmp"
	v6Type    = "ip6:icmp"
	v4UDPType = "udp4" // unprivileged datagram-oriented ICMPv4
	v6UDPType = "udp6" // unprivileged datagram-oriented ICMPv6
)

type response struct {
	t   time.Time
	err error
}

type outstanding struct {
	ch   chan response
	data []byte
}

// PacketListener defines the interface required to listen to packages
// on an address.
type ListenPacketer interface {
	ListenPacket(ctx context.Context, typ string, addr string) (net.PacketConn, error)
}

// 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 {
	// options that must be set before the first call to Send

	// Unprivileged, when set, makes the Pinger use non-privileged
	// datagram-oriented ICMP sockets ("udp4"/"udp6") opened via
	// golang.org/x/net/icmp.ListenPacket instead of raw ICMP sockets
	// ("ip4:icmp"/"ip6:icmp") opened via the configured ListenPacketer.
	//
	// Unprivileged mode is supported on macOS, iOS, and Linux (subject to
	// the /proc/sys/net/ipv4/ping_group_range sysctl). When set, the
	// ListenPacketer passed to New is ignored and the kernel rewrites the
	// outgoing ICMP echo ID to match the socket; replies are matched by
	// sequence number and echo data only.
	//
	// Must be set before the first call to Send.
	Unprivileged bool

	Verbose bool        // verbose logging
	Logf    logger.Logf // optional logging function; if nil, logs to the standard logger

	lp netx.ListenPacketFunc

	// closed guards against send incrementing the waitgroup concurrently with close.
	closed atomic.Bool

	timeNow func() time.Time
	id      uint16 // uint16 per RFC 792
	wg      sync.WaitGroup

	// Following fields protected by mu
	mu syncs.Mutex
	// conns is a map of "type" to net.PacketConn, type is either
	// "ip4:icmp" or "ip6:icmp"
	conns map[string]net.PacketConn
	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, lp netx.ListenPacketFunc) *Pinger {
	var id [2]byte
	if _, err := io.ReadFull(rand.Reader, id[:]); err != nil {
		panic("net/ping: New:" + err.Error())
	}

	return &Pinger{
		lp:      lp,
		Logf:    logf,
		timeNow: time.Now,
		id:      binary.LittleEndian.Uint16(id[:]),
		pings:   make(map[uint16]outstanding),
	}
}

func (p *Pinger) mkconn(ctx context.Context, typ, addr string) (net.PacketConn, error) {
	if p.closed.Load() {
		return nil, net.ErrClosed
	}

	var c net.PacketConn
	var err error
	if p.Unprivileged {
		// icmp.ListenPacket on "udp4"/"udp6" opens a datagram-oriented
		// ICMP socket that does not require elevated privileges. The
		// returned *icmp.PacketConn implements net.PacketConn and, on
		// Darwin/iOS, strips the IPv4 header on read via IP_STRIPHDR.
		c, err = icmp.ListenPacket(typ, addr)
	} else {
		c, err = p.lp(ctx, typ, addr)
	}
	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 := c.SetReadDeadline(deadline); err != nil {
			return nil, err
		}
	}

	p.wg.Add(1)
	go p.run(ctx, c, typ)

	return c, err
}

// getConn creates or returns a conn matching typ which is ip4:icmp
// or ip6:icmp.
func (p *Pinger) getConn(ctx context.Context, typ string) (net.PacketConn, error) {
	p.mu.Lock()
	defer p.mu.Unlock()
	if c, ok := p.conns[typ]; ok {
		return c, nil
	}

	var addr = "0.0.0.0"
	if typ == v6Type || typ == v6UDPType {
		addr = "::"
	}
	c, err := p.mkconn(ctx, typ, addr)
	if err != nil {
		return nil, err
	}
	mak.Set(&p.conns, typ, c)
	return c, 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 {
	p.closed.Store(true)

	p.mu.Lock()
	conns := p.conns
	p.conns = nil
	p.mu.Unlock()

	var errs []error
	for _, c := range conns {
		if err := c.Close(); err != nil {
			errs = append(errs, err)
		}
	}

	p.wg.Wait()
	p.cleanupOutstanding()

	return errors.Join(errs...)
}

func (p *Pinger) run(ctx context.Context, conn net.PacketConn, typ string) {
	defer p.wg.Done()
	defer func() {
		conn.Close()
		p.mu.Lock()
		delete(p.conns, typ)
		p.mu.Unlock()
	}()
	buf := make([]byte, 1500)

loop:
	for {
		select {
		case <-ctx.Done():
			break loop
		default:
		}

		n, _, err := conn.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], p.timeNow(), typ)
	}
}

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, now time.Time, typ string) {
	// We need to handle responding to both IPv4
	// and IPv6.
	var icmpType icmp.Type
	switch typ {
	case v4Type, v4UDPType:
		icmpType = ipv4.ICMPTypeEchoReply
	case v6Type, v6UDPType:
		icmpType = ipv6.ICMPTypeEchoReply
	default:
		p.vlogf("handleResponse: unknown icmp.Type")
		return
	}

	m, err := icmp.ParseMessage(icmpType.Protocol(), buf)
	if err != nil {
		p.vlogf("handleResponse: invalid packet: %v", err)
		return
	}

	if m.Type != icmpType {
		p.vlogf("handleResponse: wanted m.Type=%d; got %d", icmpType, 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.
	// In unprivileged ICMP DGRAM mode the kernel rewrites the ID to match
	// the socket, so the value we set on the way out is not what comes
	// back; rely on sequence and data matching instead.
	if !p.Unprivileged && 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()

	// Check whether the address is IPv4 or IPv6 to
	// determine the icmp.Type and conn to use.
	var conn net.PacketConn
	var icmpType icmp.Type = ipv4.ICMPTypeEcho
	ap, err := netip.ParseAddr(dest.String())
	if err != nil {
		return 0, err
	}
	if ap.Is6() {
		icmpType = ipv6.ICMPTypeEchoRequest
		typ := v6Type
		if p.Unprivileged {
			typ = v6UDPType
		}
		conn, err = p.getConn(ctx, typ)
	} else {
		typ := v4Type
		if p.Unprivileged {
			typ = v4UDPType
		}
		conn, err = p.getConn(ctx, typ)
	}
	if err != nil {
		return 0, err
	}

	// In unprivileged ICMP DGRAM mode (icmp.ListenPacket on "udp4"/"udp6"),
	// the kernel requires a *net.UDPAddr destination for WriteTo even though
	// the wire packet is ICMP.
	writeDst := dest
	if p.Unprivileged {
		writeDst = &net.UDPAddr{IP: ap.AsSlice(), Zone: ap.Zone()}
	}

	m := icmp.Message{
		Type: icmpType,
		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 := conn.WriteTo(b, writeDst)
	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()
	}
}
