feat: port hopping random interval

This commit is contained in:
Toby 2026-03-29 14:00:12 -07:00
parent 82d9935c85
commit 6ef838d2c8
4 changed files with 183 additions and 15 deletions

View file

@ -80,7 +80,9 @@ type clientConfig struct {
}
type clientConfigTransportUDP struct {
HopInterval time.Duration `mapstructure:"hopInterval"`
HopInterval time.Duration `mapstructure:"hopInterval"`
MinHopInterval time.Duration `mapstructure:"minHopInterval"`
MaxHopInterval time.Duration `mapstructure:"maxHopInterval"`
}
type clientConfigTransport struct {
@ -232,12 +234,16 @@ func (c *clientConfig) fillConnFactory(hyConfig *client.Config) error {
}
// Inner PacketConn
var newFunc func(addr net.Addr) (net.PacketConn, error)
hopInterval, err := c.Transport.UDP.hopIntervalConfig()
if err != nil {
return configError{Field: "transport.udp", Err: err}
}
switch strings.ToLower(c.Transport.Type) {
case "", "udp":
if hyConfig.ServerAddr.Network() == "udphop" {
hopAddr := hyConfig.ServerAddr.(*udphop.UDPHopAddr)
newFunc = func(addr net.Addr) (net.PacketConn, error) {
return udphop.NewUDPHopPacketConn(hopAddr, c.Transport.UDP.HopInterval, so.ListenUDP)
return udphop.NewUDPHopPacketConn(hopAddr, hopInterval, so.ListenUDP)
}
} else {
newFunc = func(addr net.Addr) (net.PacketConn, error) {
@ -249,7 +255,6 @@ func (c *clientConfig) fillConnFactory(hyConfig *client.Config) error {
}
// Obfuscation
var ob obfs.Obfuscator
var err error
switch strings.ToLower(c.Obfs.Type) {
case "", "plain":
// Keep it nil
@ -268,6 +273,25 @@ func (c *clientConfig) fillConnFactory(hyConfig *client.Config) error {
return nil
}
func (c clientConfigTransportUDP) hopIntervalConfig() (udphop.HopIntervalConfig, error) {
if c.HopInterval != 0 && (c.MinHopInterval != 0 || c.MaxHopInterval != 0) {
return udphop.HopIntervalConfig{}, errors.New("hopInterval cannot be used together with minHopInterval or maxHopInterval")
}
if c.MinHopInterval == 0 && c.MaxHopInterval == 0 {
if c.HopInterval == 0 {
return udphop.HopIntervalConfig{}, nil
}
return udphop.HopIntervalConfig{Min: c.HopInterval, Max: c.HopInterval}, nil
}
if c.MinHopInterval == 0 || c.MaxHopInterval == 0 {
return udphop.HopIntervalConfig{}, errors.New("minHopInterval and maxHopInterval must both be set")
}
return udphop.HopIntervalConfig{
Min: c.MinHopInterval,
Max: c.MaxHopInterval,
}, nil
}
func (c *clientConfig) fillAuth(hyConfig *client.Config) error {
hyConfig.Auth = c.Auth
return nil

View file

@ -242,6 +242,48 @@ func TestClientFillCongestionConfig(t *testing.T) {
})
}
func TestClientTransportUDPHopIntervalConfig(t *testing.T) {
t.Run("fixed interval", func(t *testing.T) {
cfg, err := (clientConfigTransportUDP{HopInterval: 30 * time.Second}).hopIntervalConfig()
assert.NoError(t, err)
assert.Equal(t, 30*time.Second, cfg.Min)
assert.Equal(t, 30*time.Second, cfg.Max)
})
t.Run("range interval", func(t *testing.T) {
cfg, err := (clientConfigTransportUDP{
MinHopInterval: 10 * time.Second,
MaxHopInterval: 30 * time.Second,
}).hopIntervalConfig()
assert.NoError(t, err)
assert.Equal(t, 10*time.Second, cfg.Min)
assert.Equal(t, 30*time.Second, cfg.Max)
})
t.Run("default interval", func(t *testing.T) {
cfg, err := (clientConfigTransportUDP{}).hopIntervalConfig()
assert.NoError(t, err)
assert.Zero(t, cfg.Min)
assert.Zero(t, cfg.Max)
})
t.Run("rejects mixed fields", func(t *testing.T) {
_, err := (clientConfigTransportUDP{
HopInterval: 30 * time.Second,
MinHopInterval: 10 * time.Second,
MaxHopInterval: 30 * time.Second,
}).hopIntervalConfig()
assert.EqualError(t, err, "hopInterval cannot be used together with minHopInterval or maxHopInterval")
})
t.Run("rejects partial range", func(t *testing.T) {
_, err := (clientConfigTransportUDP{
MinHopInterval: 10 * time.Second,
}).hopIntervalConfig()
assert.EqualError(t, err, "minHopInterval and maxHopInterval must both be set")
})
}
func stringRef(s string) *string {
return &s
}

View file

@ -2,8 +2,11 @@ package udphop
import (
"errors"
"fmt"
"math/rand"
"net"
"os"
"strconv"
"sync"
"syscall"
"time"
@ -14,12 +17,19 @@ const (
udpBufferSize = 2048 // QUIC packets are at most 1500 bytes long, so 2k should be more than enough
defaultHopInterval = 30 * time.Second
debugEnv = "HYSTERIA_UDPHOP_DEBUG"
)
type HopIntervalConfig struct {
Min time.Duration
Max time.Duration
}
type udpHopPacketConn struct {
Addr net.Addr
Addrs []net.Addr
HopInterval time.Duration
HopInterval HopIntervalConfig
ListenUDPFunc ListenUDPFunc
connMutex sync.RWMutex
@ -38,6 +48,7 @@ type udpHopPacketConn struct {
closed bool
bufPool sync.Pool
debug bool
}
type udpPacket struct {
@ -49,11 +60,10 @@ type udpPacket struct {
type ListenUDPFunc = func() (net.PacketConn, error)
func NewUDPHopPacketConn(addr *UDPHopAddr, hopInterval time.Duration, listenUDPFunc ListenUDPFunc) (net.PacketConn, error) {
if hopInterval == 0 {
hopInterval = defaultHopInterval
} else if hopInterval < 5*time.Second {
return nil, errors.New("hop interval must be at least 5 seconds")
func NewUDPHopPacketConn(addr *UDPHopAddr, hopInterval HopIntervalConfig, listenUDPFunc ListenUDPFunc) (net.PacketConn, error) {
hopInterval, err := hopInterval.normalized()
if err != nil {
return nil, err
}
if listenUDPFunc == nil {
listenUDPFunc = func() (net.PacketConn, error) {
@ -68,6 +78,7 @@ func NewUDPHopPacketConn(addr *UDPHopAddr, hopInterval time.Duration, listenUDPF
if err != nil {
return nil, err
}
debug, _ := strconv.ParseBool(os.Getenv(debugEnv))
hConn := &udpHopPacketConn{
Addr: addr,
Addrs: addrs,
@ -83,12 +94,32 @@ func NewUDPHopPacketConn(addr *UDPHopAddr, hopInterval time.Duration, listenUDPF
return make([]byte, udpBufferSize)
},
},
debug: debug,
}
if hConn.debug {
hConn.debugPrint("Initialized: local=%s target=%s interval=%s", curConn.LocalAddr(), addr, hopInterval)
}
go hConn.recvLoop(curConn)
go hConn.hopLoop()
return hConn, nil
}
func (c HopIntervalConfig) normalized() (HopIntervalConfig, error) {
if c.Min == 0 && c.Max == 0 {
return HopIntervalConfig{Min: defaultHopInterval, Max: defaultHopInterval}, nil
}
if c.Min == 0 || c.Max == 0 {
return HopIntervalConfig{}, errors.New("min and max hop interval must both be set")
}
if c.Min > c.Max {
return HopIntervalConfig{}, errors.New("min hop interval must not be greater than max hop interval")
}
if c.Min < 5*time.Second {
return HopIntervalConfig{}, errors.New("hop interval must be at least 5 seconds")
}
return c, nil
}
func (u *udpHopPacketConn) recvLoop(conn net.PacketConn) {
for {
buf := u.bufPool.Get().([]byte)
@ -115,19 +146,30 @@ func (u *udpHopPacketConn) recvLoop(conn net.PacketConn) {
}
func (u *udpHopPacketConn) hopLoop() {
ticker := time.NewTicker(u.HopInterval)
defer ticker.Stop()
next := u.nextHopInterval()
timer := time.NewTimer(next)
defer timer.Stop()
for {
select {
case <-ticker.C:
u.hop()
case <-timer.C:
hopInterval := next
u.hop(hopInterval)
next = u.nextHopInterval()
timer.Reset(next)
case <-u.closeChan:
return
}
}
}
func (u *udpHopPacketConn) hop() {
func (u *udpHopPacketConn) nextHopInterval() time.Duration {
if u.HopInterval.Min == u.HopInterval.Max {
return u.HopInterval.Min
}
return u.HopInterval.Min + time.Duration(rand.Int63n(int64(u.HopInterval.Max-u.HopInterval.Min)+1))
}
func (u *udpHopPacketConn) hop(hopInterval time.Duration) {
u.connMutex.Lock()
defer u.connMutex.Unlock()
if u.closed {
@ -136,6 +178,9 @@ func (u *udpHopPacketConn) hop() {
newConn, err := u.ListenUDPFunc()
if err != nil {
// Could be temporary, just skip this hop
if u.debug {
u.debugPrint("Hop skipped: listen failed: %v", err)
}
return
}
// We need to keep receiving packets from the previous connection,
@ -169,7 +214,14 @@ func (u *udpHopPacketConn) hop() {
}
go u.recvLoop(newConn)
// Update addrIndex to a new random value
prevRemote := u.Addrs[u.addrIndex]
u.addrIndex = rand.Intn(len(u.Addrs))
if u.debug {
u.debugPrint("Hop after %s: local=%s -> %s remote=%s -> %s",
formatHopInterval(hopInterval),
u.prevConn.LocalAddr(), u.currentConn.LocalAddr(),
prevRemote, u.Addrs[u.addrIndex])
}
}
func (u *udpHopPacketConn) ReadFrom(b []byte) (n int, addr net.Addr, err error) {
@ -311,3 +363,14 @@ func trySetWriteBuffer(pc net.PacketConn, bytes int) error {
}
return nil
}
func (u *udpHopPacketConn) debugPrint(format string, a ...any) {
fmt.Printf("[UDPHop] [%s] %s\n",
time.Now().Format("15:04:05"),
fmt.Sprintf(format, a...))
}
func formatHopInterval(d time.Duration) string {
seconds := d.Seconds()
return fmt.Sprintf("%.2fs", seconds)
}

View file

@ -134,9 +134,48 @@ func TestHopReappliesStoredDeadlines(t *testing.T) {
require.NoError(t, u.SetReadDeadline(readDeadline))
require.NoError(t, u.SetWriteDeadline(writeDeadline))
u.hop()
u.hop(time.Second)
require.Empty(t, secondConn.setDeadlineCalls)
require.Equal(t, []time.Time{readDeadline}, secondConn.setReadDeadlineCalls)
require.Equal(t, []time.Time{writeDeadline}, secondConn.setWriteDeadlineCalls)
}
func TestHopIntervalConfigNormalized(t *testing.T) {
t.Run("defaults", func(t *testing.T) {
cfg, err := (HopIntervalConfig{}).normalized()
require.NoError(t, err)
require.Equal(t, defaultHopInterval, cfg.Min)
require.Equal(t, defaultHopInterval, cfg.Max)
})
t.Run("rejects partial range", func(t *testing.T) {
_, err := (HopIntervalConfig{Min: 10 * time.Second}).normalized()
require.EqualError(t, err, "min and max hop interval must both be set")
})
t.Run("rejects reversed range", func(t *testing.T) {
_, err := (HopIntervalConfig{Min: 30 * time.Second, Max: 10 * time.Second}).normalized()
require.EqualError(t, err, "min hop interval must not be greater than max hop interval")
})
t.Run("rejects too short interval", func(t *testing.T) {
_, err := (HopIntervalConfig{Min: 4 * time.Second, Max: 6 * time.Second}).normalized()
require.EqualError(t, err, "hop interval must be at least 5 seconds")
})
}
func TestNextHopIntervalWithinRange(t *testing.T) {
u := &udpHopPacketConn{
HopInterval: HopIntervalConfig{
Min: 10 * time.Second,
Max: 30 * time.Second,
},
}
for i := 0; i < 1000; i++ {
d := u.nextHopInterval()
require.GreaterOrEqual(t, d, 10*time.Second)
require.LessOrEqual(t, d, 30*time.Second)
}
}