Browse Source

Random endpoint (#17)

Introduces hoppingclient subcommand to select random endpoint on each connection attempt.
Snawoot 1 year ago
parent
commit
a1cc89d92a

+ 40 - 0
README.md

@@ -66,12 +66,52 @@ $ dtlspipe -h
 Usage:
 
 dtlspipe [OPTION]... server <BIND ADDRESS> <REMOTE ADDRESS>
+
+  Run server listening on BIND ADDRESS for DTLS datagrams and forwarding decrypted UDP datagrams to REMOTE ADDRESS.
+
 dtlspipe [OPTION]... client <BIND ADDRESS> <REMOTE ADDRESS>
+
+  Run client listening on BIND ADDRESS for UDP datagrams and forwarding encrypted DTLS datagrams to REMOTE ADDRESS.
+
+dtlspipe [OPTION]... hoppingclient <BIND ADDRESS> <ENDPOINT GROUP> [ENDPOINT GROUP]...
+
+  Run client listening on BIND ADDRESS for UDP datagrams and forwarding encrypted DTLS datagrams to a random chosen endpoints.
+
+  Endpoints are specified by a list of one or more ENDPOINT GROUP. ENDPOINT GROUP syntax is defined by following ABNF:
+
+    ENDPOINT-GROUP = address-term *( "," address-term ) ":" Port
+    endpoint-term = Domain / IP-range / IP-prefix / IP-address
+    Domain = <Defined in Section 4.1.2 of [RFC5321]>
+    IP-range = ( IPv4address ".." IPv4address ) / ( IPv6address ".." IPv6address )
+    IP-prefix = IP-address "/" 1*DIGIT
+    IP-address = IPv6address / IPv4address
+    IPv4address = <Defined in Section 4.1 of [RFC5954]>
+    IPv6address = <Defined in Section 4.1 of [RFC5954]>
+
+  Endpoint is chosen randomly as follows.
+  First, random ENDPOINT GROUP is chosen with equal probability.
+  Next, address is chosen from address sets specified by that group, with probability
+  proportional to size of that set. Domain names and single addresses condidered 
+  as sets having size 1, ranges and prefixes have size as count of addresses in it.
+
+  Example: 'example.org:20000-50000' '192.168.0.0/16,10.0.0.0/8,172.16.0.0-172.31.255.255:50000-60000'
+
 dtlspipe [OPTION]... genpsk
+
+  Generate and output PSK.
+
 dtlspipe ciphers
+
+  Print list of supported ciphers and exit.
+
 dtlspipe curves
+
+  Print list of supported elliptic curves and exit.
+
 dtlspipe version
 
+  Print program version and exit.
+
 Options:
   -ciphers value
     	colon-separated list of ciphers to use

+ 150 - 0
addrgen/addrgen.go

@@ -0,0 +1,150 @@
+package addrgen
+
+import (
+	"errors"
+	"fmt"
+	"math/big"
+	"math/rand"
+	"net"
+	"slices"
+	"strconv"
+	"strings"
+
+	"github.com/Snawoot/dtlspipe/randpool"
+)
+
+type AddrGen interface {
+	Addr() string
+	Power() *big.Int
+}
+
+type PortGen interface {
+	Port() uint16
+	Power() uint16
+}
+
+type EndpointGen interface {
+	Endpoint() string
+	Power() *big.Int
+}
+
+var _ EndpointGen = &AddrSet{}
+
+type AddrSet struct {
+	portRange  PortGen
+	addrRanges []AddrGen
+	cumWeights []*big.Int
+}
+
+func ParseAddrSet(spec string) (*AddrSet, error) {
+	lastColonIdx := strings.LastIndex(spec, ":")
+	if lastColonIdx == -1 {
+		return nil, errors.New("port specification not found - colon is missing")
+	}
+	addrPart := spec[:lastColonIdx]
+	portPart := spec[lastColonIdx+1:]
+	portRange, err := ParsePortRangeSpec(portPart)
+	if err != nil {
+		return nil, fmt.Errorf("unable to parse port part: %w", err)
+	}
+
+	terms := strings.Split(addrPart, ",")
+	addrRanges := make([]AddrGen, 0, len(terms))
+	for _, addrRangeSpec := range terms {
+		r, err := ParseAddrRangeSpec(addrRangeSpec)
+		if err != nil {
+			return nil, fmt.Errorf("addr range spec %q parse failed: %w", addrRangeSpec, err)
+		}
+		addrRanges = append(addrRanges, r)
+	}
+	if len(addrRanges) == 0 {
+		return nil, errors.New("no valid address ranges specified")
+	}
+
+	cumWeights := make([]*big.Int, len(addrRanges))
+	currSum := new(big.Int)
+	for i, r := range addrRanges {
+		currSum.Add(currSum, r.Power())
+		cumWeights[i] = new(big.Int).Set(currSum)
+	}
+	return &AddrSet{
+		portRange:  portRange,
+		addrRanges: addrRanges,
+		cumWeights: cumWeights,
+	}, nil
+}
+
+func (as *AddrSet) Endpoint() string {
+	port := as.portRange.Port()
+	count := len(as.addrRanges)
+	limit := as.cumWeights[count-1]
+	random := new(big.Int)
+	randpool.Borrow(func(r *rand.Rand) {
+		random.Rand(r, limit)
+	})
+	idx, found := slices.BinarySearchFunc(as.cumWeights, random, func(elem, target *big.Int) int {
+		return elem.Cmp(target)
+	})
+	if found {
+		idx++
+	}
+	addr := as.addrRanges[idx].Addr()
+	return net.JoinHostPort(addr, strconv.FormatUint(uint64(port), 10))
+}
+
+func (as *AddrSet) Power() *big.Int {
+	power := big.NewInt(int64(as.portRange.Power()))
+	power.Mul(power, as.cumWeights[len(as.addrRanges)-1])
+	return power
+}
+
+var _ EndpointGen = EqualMultiEndpointGen(nil)
+
+type EqualMultiEndpointGen []EndpointGen
+
+func NewEqualMultiEndpointGen(gens ...EndpointGen) (EqualMultiEndpointGen, error) {
+	if len(gens) < 1 {
+		return nil, errors.New("no generators provides")
+	}
+	return EqualMultiEndpointGen(gens), nil
+}
+
+func EqualMultiEndpointGenFromSpecs(specs []string) (EqualMultiEndpointGen, error) {
+	gens := make([]EndpointGen, 0, len(specs))
+	for _, spec := range specs {
+		g, err := ParseAddrSet(spec)
+		if err != nil {
+			return nil, fmt.Errorf("can't create endpoint gen from spec %q: %w", spec, err)
+		}
+		gens = append(gens, g)
+	}
+	return NewEqualMultiEndpointGen(gens...)
+}
+
+func (g EqualMultiEndpointGen) Endpoint() string {
+	var ret string
+	randpool.Borrow(func(r *rand.Rand) {
+		ret = g[r.Intn(len(g))].Endpoint()
+	})
+	return ret
+}
+
+func (g EqualMultiEndpointGen) Power() *big.Int {
+	sum := new(big.Int)
+	for _, sg := range g {
+		sum.Add(sum, sg.Power())
+	}
+	return sum
+}
+
+var _ EndpointGen = SingleEndpoint("")
+
+type SingleEndpoint string
+
+func (e SingleEndpoint) Endpoint() string {
+	return string(e)
+}
+
+func (e SingleEndpoint) Power() *big.Int {
+	return big.NewInt(1)
+}

+ 25 - 0
addrgen/addrgen_test.go

@@ -0,0 +1,25 @@
+package addrgen
+
+import (
+	"strings"
+	"testing"
+)
+
+func TestAddrGen1(t *testing.T) {
+	g := must(ParseAddrSet("10.0.0.0/17,192.168.0.0..192.168.255.255:20000-50000"))
+	var a, b int
+	for i := 0; i < 100; i++ {
+		s := g.Endpoint()
+		switch {
+		case strings.HasPrefix(s, "10.0."):
+			a++
+		case strings.HasPrefix(s, "192.168."):
+			b++
+		default:
+			t.Errorf("unexpected value: %q", s)
+		}
+	}
+	if a > b {
+		t.Errorf("%d > %d", a, b)
+	}
+}

+ 74 - 0
addrgen/port.go

@@ -0,0 +1,74 @@
+package addrgen
+
+import (
+	"fmt"
+	"math/rand"
+	"strconv"
+	"strings"
+
+	"github.com/Snawoot/dtlspipe/randpool"
+)
+
+var _ PortGen = PortRange{}
+
+type PortRange struct {
+	portBase uint16
+	portNum  uint16
+}
+
+func NewPortRange(start, end uint16) PortRange {
+	if end < start {
+		return NewPortRange(end, start)
+	}
+	return PortRange{
+		portBase: start,
+		portNum:  end - start + 1,
+	}
+}
+
+func (p PortRange) Port() uint16 {
+	var delta uint16
+	randpool.Borrow(func(r *rand.Rand) {
+		delta = uint16(r.Intn(int(p.portNum)))
+	})
+	return p.portBase + delta
+}
+
+func (p PortRange) Power() uint16 {
+	return p.portNum
+}
+
+var _ PortGen = SinglePort(0)
+
+type SinglePort uint16
+
+func (p SinglePort) Port() uint16 {
+	return uint16(p)
+}
+
+func (p SinglePort) Power() uint16 {
+	return 1
+}
+
+func ParsePortRangeSpec(spec string) (PortGen, error) {
+	parts := strings.SplitN(spec, "-", 2)
+	switch len(parts) {
+	case 1:
+		port, err := strconv.ParseUint(parts[0], 10, 16)
+		if err != nil {
+			return nil, fmt.Errorf("unable to parse port specification %q: %w", parts[0], err)
+		}
+		return SinglePort(port), nil
+	case 2:
+		start, err := strconv.ParseUint(parts[0], 10, 16)
+		if err != nil {
+			return nil, fmt.Errorf("unable to parse port specification %q: %w", parts[0], err)
+		}
+		end, err := strconv.ParseUint(parts[1], 10, 16)
+		if err != nil {
+			return nil, fmt.Errorf("unable to parse port specification %q: %w", parts[1], err)
+		}
+		return NewPortRange(uint16(start), uint16(end)), nil
+	}
+	return nil, fmt.Errorf("unexpected number of components: %d", len(parts))
+}

+ 48 - 0
addrgen/port_test.go

@@ -0,0 +1,48 @@
+package addrgen
+
+import (
+	"math"
+	"testing"
+)
+
+const testArraySize = 100
+const testIterCount = 100000
+
+func TestPortSimple(t *testing.T) {
+	g := must(ParsePortRangeSpec("443"))
+	for i := 0; i < 100; i++ {
+		if p := g.Port(); p != 443 {
+			t.Errorf("unexpected port value: %d", p)
+		}
+	}
+}
+
+func TestPortRange(t *testing.T) {
+	var arr [testArraySize]int
+	g := must(ParsePortRangeSpec("10000-20000"))
+
+	for i := 0; i < testIterCount; i++ {
+		p := g.Port()
+		arr[p%testArraySize]++
+	}
+
+	sum := 0
+	for i := 0; i < testArraySize; i++ {
+		sum += int(arr[i])
+	}
+	if sum != testIterCount {
+		t.Errorf("unexpected sum: %d", sum)
+	}
+
+	mx := float64(testIterCount) / float64(testArraySize)
+	sigmaSquared := mx * float64(testArraySize-1) / float64(testArraySize)
+	sigma := math.Sqrt(sigmaSquared)
+	t.Logf("sigma = %.3f", sigma)
+	t.Logf("5*sigma = %.3f", 5*sigma)
+
+	for i := 0; i < testArraySize; i++ {
+		if math.Abs(float64(arr[i])-mx) > 5*sigma {
+			t.Errorf("arr[%d]=%d too far from mx=%.3f", i, arr[i], mx)
+		}
+	}
+}

+ 121 - 0
addrgen/range.go

@@ -0,0 +1,121 @@
+package addrgen
+
+import (
+	"errors"
+	"fmt"
+	"math/big"
+	"math/rand"
+	"net/netip"
+	"strings"
+
+	"github.com/Snawoot/dtlspipe/randpool"
+)
+
+type AddrRange struct {
+	base *big.Int
+	size *big.Int
+	v6   bool
+}
+
+var _ AddrGen = &AddrRange{}
+
+func NewAddrRange(start, end netip.Addr) (*AddrRange, error) {
+	if start.BitLen() != end.BitLen() {
+		return nil, errors.New("addr bit length mismatch - one of them is IPv4, another is IPv6")
+	}
+	if end.Less(start) {
+		return NewAddrRange(end, start)
+	}
+
+	base := new(big.Int)
+	base.SetBytes(start.AsSlice())
+	upper := new(big.Int)
+	upper.SetBytes(end.AsSlice())
+
+	size := new(big.Int)
+	size.Sub(upper, base)
+	size.Add(size, big.NewInt(1))
+
+	return &AddrRange{
+		base: base,
+		size: size,
+		v6:   start.BitLen() == 128,
+	}, nil
+}
+
+func NewAddrRangeFromPrefix(pfx netip.Prefix) (*AddrRange, error) {
+	if !pfx.IsValid() {
+		return nil, errors.New("invalid prefix")
+	}
+	pfx = pfx.Masked()
+	addr := pfx.Addr()
+	base := new(big.Int)
+	base.SetBytes(addr.AsSlice())
+	pfxPower := addr.BitLen() - pfx.Bits()
+	size := big.NewInt(1)
+	size.Lsh(size, uint(pfxPower))
+	return &AddrRange{
+		base: base,
+		size: size,
+		v6:   addr.BitLen() == 128,
+	}, nil
+}
+
+func (ar *AddrRange) Addr() string {
+	res := new(big.Int)
+	randpool.Borrow(func(r *rand.Rand) {
+		res.Rand(r, ar.size)
+	})
+	res.Add(ar.base, res)
+	var resArr [16]byte
+	resSlice := resArr[:]
+	if !ar.v6 {
+		resSlice = resSlice[:4]
+	}
+	res.FillBytes(resSlice)
+	resAddr, ok := netip.AddrFromSlice(resSlice[:])
+	if !ok {
+		panic("can't parse address from slice")
+	}
+	return resAddr.String()
+}
+
+func (ar *AddrRange) Power() *big.Int {
+	res := new(big.Int)
+	res.Set(ar.size)
+	return res
+}
+
+func ParseAddrRangeSpec(spec string) (AddrGen, error) {
+	switch {
+	case strings.Contains(spec, "/"):
+		pfx, err := netip.ParsePrefix(spec)
+		if err != nil {
+			return nil, fmt.Errorf("unable to parse prefix %q: %w", spec, err)
+		}
+		if pfx.IsSingleIP() {
+			return SingleAddr(pfx.Addr().String()), nil
+		}
+		r, err := NewAddrRangeFromPrefix(pfx)
+		if err != nil {
+			return nil, fmt.Errorf("unable to parse range spec %q: %w", spec, err)
+		}
+		return r, nil
+	case strings.Contains(spec, ".."):
+		parts := strings.SplitN(spec, "..", 2)
+		start, err := netip.ParseAddr(parts[0])
+		if err != nil {
+			return nil, fmt.Errorf("unable to parse addr %q: %w", parts[0], err)
+		}
+		end, err := netip.ParseAddr(parts[1])
+		if err != nil {
+			return nil, fmt.Errorf("unable to parse addr %q: %w", parts[1], err)
+		}
+		r, err := NewAddrRange(start, end)
+		if err != nil {
+			return nil, fmt.Errorf("invalid range spec %q: %w", spec, err)
+		}
+		return r, nil
+	}
+	return SingleAddr(spec), nil
+}

+ 31 - 0
addrgen/range_test.go

@@ -0,0 +1,31 @@
+package addrgen
+
+import (
+	"math/big"
+	"net/netip"
+	"testing"
+)
+
+func must[T any](x T, err error) T {
+	if err != nil {
+		panic(err)
+	}
+	return x
+}
+
+func TestAddrRangeSingle(t *testing.T) {
+	for _, sample := range []string{"127.0.0.1", "0.0.0.0", "::1", "255.255.255.255", "::"} {
+		a := netip.MustParseAddr(sample)
+		r := must(NewAddrRange(a, a))
+		if res := r.Addr(); res != sample {
+			t.Errorf("expected: %q; got: %q", sample, res)
+		}
+	}
+}
+
+func TestAddrRangePower(t *testing.T) {
+	r := must(NewAddrRange(netip.MustParseAddr("127.0.0.1"), netip.MustParseAddr("127.0.0.10")))
+	if res := r.Power(); big.NewInt(10).Cmp(res) != 0 {
+		t.Errorf("expected: %s, got: %s", big.NewInt(10).String(), res.String())
+	}
+}

+ 15 - 0
addrgen/single.go

@@ -0,0 +1,15 @@
+package addrgen
+
+import "math/big"
+
+type SingleAddr string
+
+var _ AddrGen = SingleAddr("")
+
+func (n SingleAddr) Addr() string {
+	return string(n)
+}
+
+func (n SingleAddr) Power() *big.Int {
+	return big.NewInt(1)
+}

+ 16 - 0
addrgen/single_test.go

@@ -0,0 +1,16 @@
+package addrgen
+
+import (
+	"math/big"
+	"testing"
+)
+
+func TestSingleAddr(t *testing.T) {
+	s := "example.com"
+	if r := SingleAddr(s).Addr(); r != s {
+		t.Errorf("expected: %q, got: %q", s, r)
+	}
+	if r := SingleAddr(s).Power(); big.NewInt(1).Cmp(r) != 0 {
+		t.Errorf("expected: %s, got: %s", big.NewInt(1).String(), r)
+	}
+}

+ 3 - 3
client/client.go

@@ -22,7 +22,7 @@ const (
 type Client struct {
 	listener      net.Listener
 	dtlsConfig    *dtls.Config
-	rAddr         string
+	remoteDialFn  func(context.Context, string) (net.Conn, error)
 	psk           func([]byte) ([]byte, error)
 	timeout       time.Duration
 	idleTimeout   time.Duration
@@ -40,7 +40,7 @@ func New(cfg *Config) (*Client, error) {
 	baseCtx, cancelCtx := context.WithCancel(cfg.BaseContext)
 
 	client := &Client{
-		rAddr:         cfg.RemoteAddress,
+		remoteDialFn:  cfg.RemoteDialFunc,
 		timeout:       cfg.Timeout,
 		psk:           cfg.PSKCallback,
 		idleTimeout:   cfg.IdleTimeout,
@@ -119,7 +119,7 @@ func (client *Client) serve(conn net.Conn) {
 
 	dialCtx, cancel := context.WithTimeout(ctx, client.timeout)
 	defer cancel()
-	remoteConn, err := (&net.Dialer{}).DialContext(dialCtx, "udp", client.rAddr)
+	remoteConn, err := client.remoteDialFn(dialCtx, "udp")
 	if err != nil {
 		log.Printf("remote dial failed: %v", err)
 		return

+ 1 - 1
client/config.go

@@ -11,7 +11,7 @@ import (
 
 type Config struct {
 	BindAddress    string
-	RemoteAddress  string
+	RemoteDialFunc func(ctx context.Context, network string) (net.Conn, error)
 	Timeout        time.Duration
 	IdleTimeout    time.Duration
 	BaseContext    context.Context

+ 110 - 2
cmd/dtlspipe/main.go

@@ -14,6 +14,7 @@ import (
 	"syscall"
 	"time"
 
+	"github.com/Snawoot/dtlspipe/addrgen"
 	"github.com/Snawoot/dtlspipe/ciphers"
 	"github.com/Snawoot/dtlspipe/client"
 	"github.com/Snawoot/dtlspipe/keystore"
@@ -154,12 +155,52 @@ func usage() {
 	fmt.Fprintln(out, "Usage:")
 	fmt.Fprintln(out)
 	fmt.Fprintf(out, "%s [OPTION]... server <BIND ADDRESS> <REMOTE ADDRESS>\n", ProgName)
+	fmt.Fprintln(out)
+	fmt.Fprintln(out, "  Run server listening on BIND ADDRESS for DTLS datagrams and forwarding decrypted UDP datagrams to REMOTE ADDRESS.")
+	fmt.Fprintln(out)
 	fmt.Fprintf(out, "%s [OPTION]... client <BIND ADDRESS> <REMOTE ADDRESS>\n", ProgName)
+	fmt.Fprintln(out)
+	fmt.Fprintln(out, "  Run client listening on BIND ADDRESS for UDP datagrams and forwarding encrypted DTLS datagrams to REMOTE ADDRESS.")
+	fmt.Fprintln(out)
+	fmt.Fprintf(out, "%s [OPTION]... hoppingclient <BIND ADDRESS> <ENDPOINT GROUP> [ENDPOINT GROUP]...\n", ProgName)
+	fmt.Fprintln(out)
+	fmt.Fprintln(out, "  Run client listening on BIND ADDRESS for UDP datagrams and forwarding encrypted DTLS datagrams to a random chosen endpoints.")
+	fmt.Fprintln(out)
+	fmt.Fprintln(out, "  Endpoints are specified by a list of one or more ENDPOINT GROUP. ENDPOINT GROUP syntax is defined by following ABNF:")
+	fmt.Fprintln(out)
+	fmt.Fprintln(out, "    ENDPOINT-GROUP = address-term *( \",\" address-term ) \":\" Port")
+	fmt.Fprintln(out, "    endpoint-term = Domain / IP-range / IP-prefix / IP-address")
+	fmt.Fprintln(out, "    Domain = <Defined in Section 4.1.2 of [RFC5321]>")
+	fmt.Fprintln(out, "    IP-range = ( IPv4address \"..\" IPv4address ) / ( IPv6address \"..\" IPv6address )")
+	fmt.Fprintln(out, "    IP-prefix = IP-address \"/\" 1*DIGIT")
+	fmt.Fprintln(out, "    IP-address = IPv6address / IPv4address")
+	fmt.Fprintln(out, "    IPv4address = <Defined in Section 4.1 of [RFC5954]>")
+	fmt.Fprintln(out, "    IPv6address = <Defined in Section 4.1 of [RFC5954]>")
+	fmt.Fprintln(out)
+	fmt.Fprintln(out, "  Endpoint is chosen randomly as follows.")
+	fmt.Fprintln(out, "  First, random ENDPOINT GROUP is chosen with equal probability.")
+	fmt.Fprintln(out, "  Next, address is chosen from address sets specified by that group, with probability")
+	fmt.Fprintln(out, "  proportional to size of that set. Domain names and single addresses condidered ")
+	fmt.Fprintln(out, "  as sets having size 1, ranges and prefixes have size as count of addresses in it.")
+	fmt.Fprintln(out)
+	fmt.Fprintln(out, "  Example: 'example.org:20000-50000' '192.168.0.0/16,10.0.0.0/8,172.16.0.0-172.31.255.255:50000-60000'")
+	fmt.Fprintln(out)
 	fmt.Fprintf(out, "%s [OPTION]... genpsk\n", ProgName)
+	fmt.Fprintln(out)
+	fmt.Fprintln(out, "  Generate and output PSK.")
+	fmt.Fprintln(out)
 	fmt.Fprintf(out, "%s ciphers\n", ProgName)
+	fmt.Fprintln(out)
+	fmt.Fprintln(out, "  Print list of supported ciphers and exit.")
+	fmt.Fprintln(out)
 	fmt.Fprintf(out, "%s curves\n", ProgName)
+	fmt.Fprintln(out)
+	fmt.Fprintln(out, "  Print list of supported elliptic curves and exit.")
+	fmt.Fprintln(out)
 	fmt.Fprintf(out, "%s version\n", ProgName)
 	fmt.Fprintln(out)
+	fmt.Fprintln(out, "  Print program version and exit.")
+	fmt.Fprintln(out)
 	fmt.Fprintln(out, "Options:")
 	flag.PrintDefaults()
 }
@@ -197,8 +238,65 @@ func cmdClient(bindAddress, remoteAddress string) int {
 	defer cancel()
 
 	cfg := client.Config{
-		BindAddress:    bindAddress,
-		RemoteAddress:  remoteAddress,
+		BindAddress: bindAddress,
+		RemoteDialFunc: util.NewDynDialer(
+			addrgen.SingleEndpoint(remoteAddress).Endpoint,
+			nil,
+		).DialContext,
+		PSKCallback:    keystore.NewStaticKeystore(psk).PSKCallback,
+		PSKIdentity:    *identity,
+		Timeout:        *timeout,
+		IdleTimeout:    *idleTime,
+		BaseContext:    appCtx,
+		MTU:            *mtu,
+		CipherSuites:   ciphersuites.Value,
+		EllipticCurves: curves.Value,
+		StaleMode:      staleMode,
+		TimeLimitFunc:  util.TimeLimitFunc(timeLimit.low, timeLimit.high),
+		AllowFunc:      util.AllowByRatelimit(rateLimit.value),
+	}
+
+	clt, err := client.New(&cfg)
+	if err != nil {
+		log.Fatalf("client startup failed: %v", err)
+	}
+	defer clt.Close()
+
+	<-appCtx.Done()
+
+	return 0
+}
+
+func cmdHoppingClient(args []string) int {
+	bindAddress := args[0]
+	args = args[1:]
+	psk, err := simpleGetPSK()
+	if err != nil {
+		log.Printf("can't get PSK: %v", err)
+		return 2
+	}
+	log.Printf("starting dtlspipe client: %s =[wrap into DTLS]=> %v", bindAddress, args)
+	defer log.Println("dtlspipe client stopped")
+
+	appCtx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+	defer cancel()
+
+	gen, err := addrgen.EqualMultiEndpointGenFromSpecs(args)
+	if err != nil {
+		log.Printf("can't construct generator: %v", err)
+		return 2
+	}
+
+	cfg := client.Config{
+		BindAddress: bindAddress,
+		RemoteDialFunc: util.NewDynDialer(
+			func() string {
+				ep := gen.Endpoint()
+				log.Printf("selected new endpoint %s", ep)
+				return ep
+			},
+			nil,
+		).DialContext,
 		PSKCallback:    keystore.NewStaticKeystore(psk).PSKCallback,
 		PSKIdentity:    *identity,
 		Timeout:        *timeout,
@@ -290,6 +388,9 @@ func run() int {
 	}
 
 	switch len(args) {
+	case 0:
+		usage()
+		return 2
 	case 1:
 		switch args[0] {
 		case "genpsk":
@@ -301,6 +402,9 @@ func run() int {
 		case "version":
 			return cmdVersion()
 		}
+	case 2:
+		usage()
+		return 2
 	case 3:
 		switch args[0] {
 		case "server":
@@ -309,6 +413,10 @@ func run() int {
 			return cmdClient(args[1], args[2])
 		}
 	}
+	switch args[0] {
+	case "hoppingclient":
+		return cmdHoppingClient(args[1:])
+	}
 	usage()
 	return 2
 }

+ 46 - 0
randpool/randpool.go

@@ -0,0 +1,46 @@
+package randpool
+
+import (
+	crand "crypto/rand"
+	"encoding/binary"
+	"fmt"
+	"math/rand"
+	"sync"
+)
+
+type RandPool struct {
+	pool sync.Pool
+}
+
+var defaultPool = New()
+
+func Borrow(f func(*rand.Rand)) {
+	defaultPool.Borrow(f)
+}
+
+func MakeRand() *rand.Rand {
+	var seedBuf [8]byte
+	if _, err := crand.Read(seedBuf[:]); err != nil {
+		panic(fmt.Errorf("crypto/rand.Read failed: %w", err))
+	}
+	uSeed := binary.BigEndian.Uint64(seedBuf[:])
+	return rand.New(rand.NewSource(int64(uSeed)))
+}
+
+func poolMakeRand() any {
+	return MakeRand()
+}
+
+func New() *RandPool {
+	return &RandPool{
+		pool: sync.Pool{
+			New: poolMakeRand,
+		},
+	}
+}
+
+func (p *RandPool) Borrow(f func(*rand.Rand)) {
+	rng := p.pool.Get().(*rand.Rand)
+	defer p.pool.Put(rng)
+	f(rng)
+}

+ 40 - 0
randpool/randpool_test.go

@@ -0,0 +1,40 @@
+package randpool
+
+import (
+	"math"
+	"math/rand"
+	"testing"
+)
+
+const testArraySize = 100
+const testIterCount = 100000
+
+func TestBorrow(t *testing.T) {
+	var arr [testArraySize]int
+	rp := New()
+	rp.Borrow(func(r *rand.Rand) {
+		for i := 0; i < testIterCount; i++ {
+			arr[r.Intn(testArraySize)]++
+		}
+	})
+
+	sum := 0
+	for i := 0; i < testArraySize; i++ {
+		sum += int(arr[i])
+	}
+	if sum != testIterCount {
+		t.Errorf("unexpected sum: %d", sum)
+	}
+
+	mx := float64(testIterCount) / float64(testArraySize)
+	sigmaSquared := mx * float64(testArraySize-1) / float64(testArraySize)
+	sigma := math.Sqrt(sigmaSquared)
+	t.Logf("sigma = %.3f", sigma)
+	t.Logf("5*sigma = %.3f", 5*sigma)
+
+	for i := 0; i < testArraySize; i++ {
+		if math.Abs(float64(arr[i])-mx) > 5*sigma {
+			t.Errorf("arr[%d]=%d too far from mx=%.3f", i, arr[i], mx)
+		}
+	}
+}

+ 19 - 0
util/util.go

@@ -170,3 +170,22 @@ func TimeLimitFunc(low, high time.Duration) func() time.Duration {
 		return low + time.Duration(r.Int63n(int64(delta)))
 	}
 }
+
+type DynDialer struct {
+	dial func(context.Context, string, string) (net.Conn, error)
+	ep   func() string
+}
+
+func NewDynDialer(ep func() string, dial func(context.Context, string, string) (net.Conn, error)) DynDialer {
+	if dial == nil {
+		dial = (&net.Dialer{}).DialContext
+	}
+	return DynDialer{
+		ep:   ep,
+		dial: dial,
+	}
+}
+
+func (d DynDialer) DialContext(ctx context.Context, network string) (net.Conn, error) {
+	return d.dial(ctx, network, d.ep())
+}