diff --git a/app/cmd/client.go b/app/cmd/client.go index aa91b83..b542a30 100644 --- a/app/cmd/client.go +++ b/app/cmd/client.go @@ -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 diff --git a/app/cmd/client_test.go b/app/cmd/client_test.go index db1ca4f..5ffc52e 100644 --- a/app/cmd/client_test.go +++ b/app/cmd/client_test.go @@ -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 } diff --git a/extras/transport/udphop/conn.go b/extras/transport/udphop/conn.go index 5039ed9..d039b81 100644 --- a/extras/transport/udphop/conn.go +++ b/extras/transport/udphop/conn.go @@ -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) +} diff --git a/extras/transport/udphop/conn_test.go b/extras/transport/udphop/conn_test.go index 4c1333f..b4ba56e 100644 --- a/extras/transport/udphop/conn_test.go +++ b/extras/transport/udphop/conn_test.go @@ -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) + } +}