package main

import (
	"encoding/binary"
	"errors"
	"log"
	"net"
	"os"
	"sync"
	"syscall"
	"time"

	"golang.org/x/net/ipv4"
	"golang.org/x/net/ipv6"
)

const (
	// DefaultConnTrackTimeout is the default timeout used for UDP connection
	// tracking.
	DefaultConnTrackTimeout = 90 * time.Second
	// UDPBufSize is the buffer size for the UDP proxy
	UDPBufSize = 65507
)

// A net.Addr where the IP is split into two fields so you can use it as a key
// in a map:
type connTrackKey struct {
	IPHigh uint64
	IPLow  uint64
	Port   int
}

func newConnTrackKey(addr *net.UDPAddr) *connTrackKey {
	if len(addr.IP) == net.IPv4len {
		return &connTrackKey{
			IPHigh: 0,
			IPLow:  uint64(binary.BigEndian.Uint32(addr.IP)),
			Port:   addr.Port,
		}
	}
	return &connTrackKey{
		IPHigh: binary.BigEndian.Uint64(addr.IP[:8]),
		IPLow:  binary.BigEndian.Uint64(addr.IP[8:]),
		Port:   addr.Port,
	}
}

type connTrackMap map[connTrackKey]*connTrackEntry

// connTrackEntry wraps a UDP connection to provide thread-safe [net.Conn.Write]
// and [net.Conn.Close] operations.
type connTrackEntry struct {
	conn  *net.UDPConn
	lastW time.Time
	// This lock should be held before calling Write or Close on the wrapped
	// net.UDPConn. Read can be called concurrently to these operations.
	//
	// Never lock mu without locking UDPProxy.connTrackLock first.
	mu sync.Mutex
}

func newConnTrackEntry(conn *net.UDPConn) *connTrackEntry {
	return &connTrackEntry{
		conn: conn,
		mu:   sync.Mutex{},
	}
}

func (cte *connTrackEntry) lastWrite() time.Time {
	cte.mu.Lock()
	defer cte.mu.Unlock()
	return cte.lastW
}

// UDPProxy is proxy for which handles UDP datagrams. It implements the Proxy
// interface to handle UDP traffic forwarding between the frontend and backend
// addresses.
type UDPProxy struct {
	listener         *net.UDPConn
	frontendAddr     *net.UDPAddr
	backendAddr      *net.UDPAddr
	connTrackTable   connTrackMap
	connTrackLock    sync.Mutex
	connTrackTimeout time.Duration
	ipVer            ipVersion
}

// NewUDPProxy creates a new UDPProxy.
func NewUDPProxy(listener *net.UDPConn, backendAddr *net.UDPAddr, ipVer ipVersion) (*UDPProxy, error) {
	return &UDPProxy{
		listener:         listener,
		frontendAddr:     listener.LocalAddr().(*net.UDPAddr),
		backendAddr:      backendAddr,
		connTrackTable:   make(connTrackMap),
		connTrackTimeout: DefaultConnTrackTimeout,
		ipVer:            ipVer,
	}, nil
}

func (proxy *UDPProxy) replyLoop(cte *connTrackEntry, serverAddr net.IP, clientAddr *net.UDPAddr, clientKey *connTrackKey) {
	defer func() {
		proxy.connTrackLock.Lock()
		delete(proxy.connTrackTable, *clientKey)
		cte.mu.Lock()
		proxy.connTrackLock.Unlock()
		cte.conn.Close()
	}()

	var oob []byte
	if proxy.ipVer == ip4 {
		cm := &ipv4.ControlMessage{Src: serverAddr}
		oob = cm.Marshal()
	} else {
		cm := &ipv6.ControlMessage{Src: serverAddr}
		oob = cm.Marshal()
	}

	readBuf := make([]byte, UDPBufSize)
	for {
		cte.conn.SetReadDeadline(time.Now().Add(proxy.connTrackTimeout))
	again:
		read, err := cte.conn.Read(readBuf)
		if err != nil {
			if err, ok := err.(*net.OpError); ok && errors.Is(err.Err, syscall.ECONNREFUSED) {
				// This will happen if the last write failed
				// (e.g: nothing is actually listening on the
				// proxied port on the container), ignore it
				// and continue until DefaultConnTrackTimeout
				// expires:
				goto again
			}
			// If the UDP connection is one-sided (i.e. the backend never sends
			// replies), the connTrackEntry should not be GC'd until no writes
			// happen for proxy.connTrackTimeout.
			//
			// Since the ReadDeadline is set to proxy.connTrackTimeout, in such
			// case, the connTrackEntry will be GC'd at most after 2 * proxy.connTrackTimeout.
			if errors.Is(err, os.ErrDeadlineExceeded) && time.Since(cte.lastWrite()) < proxy.connTrackTimeout {
				continue
			}
			return
		}
		for i := 0; i != read; {
			written, _, err := proxy.listener.WriteMsgUDP(readBuf[i:read], oob, clientAddr)
			if err != nil {
				return
			}
			i += written
		}
	}
}

// Run starts forwarding the traffic using UDP.
func (proxy *UDPProxy) Run() {
	readBuf := make([]byte, UDPBufSize)
	var oob []byte
	if proxy.ipVer == ip4 {
		oob = ipv4.NewControlMessage(ipv4.FlagDst)
	} else {
		oob = ipv6.NewControlMessage(ipv6.FlagDst)
	}

	for {
		read, _, _, from, err := proxy.listener.ReadMsgUDP(readBuf, oob)
		if err != nil {
			// The frontend listener socket might be closed by the signal
			// handler. In that case, don't log anything - it's not an error.
			if !errors.Is(err, net.ErrClosed) {
				log.Printf("Stopping proxy on udp/%v for udp/%v (%s)", proxy.frontendAddr, proxy.backendAddr, err)
			}
			break
		}

		fromKey := newConnTrackKey(from)
		proxy.connTrackLock.Lock()
		cte, hit := proxy.connTrackTable[*fromKey]
		if !hit {
			proxyConn, err := net.DialUDP("udp", nil, proxy.backendAddr)
			if err != nil {
				log.Printf("Can't proxy a datagram to udp/%s: %s\n", proxy.backendAddr, err)
				proxy.connTrackLock.Unlock()
				continue
			}
			cte = newConnTrackEntry(proxyConn)
			proxy.connTrackTable[*fromKey] = cte

			daddr, err := readDestFromCmsg(oob, proxy.ipVer)
			if err != nil {
				log.Printf("Failed to parse control message: %v", err)
				proxy.connTrackLock.Unlock()
				continue
			}

			go proxy.replyLoop(cte, daddr, from, fromKey)
		}
		cte.mu.Lock()
		proxy.connTrackLock.Unlock()
		cte.conn.SetWriteDeadline(time.Now().Add(proxy.connTrackTimeout))
		for i := 0; i != read; {
			written, err := cte.conn.Write(readBuf[i:read])
			if err != nil {
				log.Printf("Can't proxy a datagram to udp/%s: %s\n", proxy.backendAddr, err)
				break
			}
			i += written
			cte.lastW = time.Now()
		}
		cte.mu.Unlock()
	}
}

func readDestFromCmsg(oob []byte, ipVer ipVersion) (_ net.IP, err error) {
	defer func() {
		// In case of partial upgrade / downgrade, docker-proxy could read
		// control messages from a socket which doesn't have the sockopt
		// IP_PKTINFO enabled. In that case, the control message will be all-0
		// and Go's ControlMessage.Parse() will report an 'invalid header
		// length' error. In that case, ignore the error and return an empty
		// daddr - the kernel will pick a source address for us anyway (but
		// maybe it'll be the wrong one).
		if err != nil && err.Error() == "invalid header length" {
			err = nil
		}
	}()

	if ipVer == ip4 {
		cm := &ipv4.ControlMessage{}
		if err := cm.Parse(oob); err != nil {
			return nil, err
		}
		return cm.Dst, nil
	}

	cm := &ipv6.ControlMessage{}
	if err := cm.Parse(oob); err != nil {
		return nil, err
	}
	return cm.Dst, nil
}

// Close ungracefully stops forwarding the traffic.
func (proxy *UDPProxy) Close() {
	proxy.listener.Close()
	proxy.connTrackLock.Lock()
	defer proxy.connTrackLock.Unlock()
	for _, cte := range proxy.connTrackTable {
		// Unlike the GC logic in replyLoop, we want to close the connections
		// immediately, even if there are pending and in-progress writes. So no
		// need to lock cte.mu here.
		cte.conn.Close()
	}
}
