evict old paths when the client probes a new path (#5034)

* store paths in a slice, not a map

No functional change expected.

* save the timestamp of the last received packet for a path

* evict old paths

* reduce the path timeout to 5s
This commit is contained in:
Marten Seemann
2025-04-19 10:10:54 +08:00
committed by GitHub
parent 7cffd500f1
commit cda52a1e36
3 changed files with 97 additions and 32 deletions

View File

@@ -3,6 +3,8 @@ package quic
import (
"crypto/rand"
"net"
"slices"
"time"
"github.com/quic-go/quic-go/internal/ackhandler"
"github.com/quic-go/quic-go/internal/protocol"
@@ -14,10 +16,21 @@ type pathID int64
const invalidPathID pathID = -1
// Maximum number of paths to keep track of.
// If the peer probes another path (before the pathTimeout of an existing path expires),
// this probing attempt is ignored.
const maxPaths = 3
// If no packet is received for a path for pathTimeout,
// the path can be evicted when the peer probes another path.
// This prevents an attacker from churning through paths by duplicating packets and
// sending them with spoofed source addresses.
const pathTimeout = 5 * time.Second
type path struct {
id pathID
addr net.Addr
lastPacketTime time.Time
pathChallenge [8]byte
validated bool
rcvdNonProbing bool
@@ -25,7 +38,8 @@ type path struct {
type pathManager struct {
nextPathID pathID
paths map[pathID]*path
// ordered by lastPacketTime, with the most recently used path at the end
paths []*path
getConnID func(pathID) (_ protocol.ConnectionID, ok bool)
retireConnID func(pathID)
@@ -39,7 +53,7 @@ func newPathManager(
logger utils.Logger,
) *pathManager {
return &pathManager{
paths: make(map[pathID]*path),
paths: make([]*path, 0, maxPaths+1),
getConnID: getConnID,
retireConnID: retireConnID,
logger: logger,
@@ -50,15 +64,15 @@ func newPathManager(
// May return nil.
func (pm *pathManager) HandlePacket(
remoteAddr net.Addr,
t time.Time,
pathChallenge *wire.PathChallengeFrame, // may be nil if the packet didn't contain a PATH_CHALLENGE
isNonProbing bool,
) (_ protocol.ConnectionID, _ []ackhandler.Frame, shouldSwitch bool) {
var p *path
pathID := pm.nextPathID
for id, path := range pm.paths {
for i, path := range pm.paths {
if addrsEqual(path.addr, remoteAddr) {
p = path
pathID = id
p.lastPacketTime = t
// already sent a PATH_CHALLENGE for this path
if isNonProbing {
path.rcvdNonProbing = true
@@ -67,6 +81,11 @@ func (pm *pathManager) HandlePacket(
pm.logger.Debugf("received packet for path %s that was already probed, validated: %t", remoteAddr, path.validated)
}
shouldSwitch = path.validated && path.rcvdNonProbing
if i != len(pm.paths)-1 {
// move the path to the end of the list
pm.paths = slices.Delete(pm.paths, i, i+1)
pm.paths = append(pm.paths, p)
}
if pathChallenge == nil {
return protocol.ConnectionID{}, nil, shouldSwitch
}
@@ -74,10 +93,22 @@ func (pm *pathManager) HandlePacket(
}
if len(pm.paths) >= maxPaths {
if pm.logger.Debug() {
pm.logger.Debugf("received packet for previously unseen path %s, but already have %d paths", remoteAddr, len(pm.paths))
if pm.paths[0].lastPacketTime.Add(pathTimeout).After(t) {
if pm.logger.Debug() {
pm.logger.Debugf("received packet for previously unseen path %s, but already have %d paths", remoteAddr, len(pm.paths))
}
return protocol.ConnectionID{}, nil, shouldSwitch
}
return protocol.ConnectionID{}, nil, shouldSwitch
// evict the oldest path, if the last packet was received more than pathTimeout ago
pm.retireConnID(pm.paths[0].id)
pm.paths = pm.paths[1:]
}
var pathID pathID
if p != nil {
pathID = p.id
} else {
pathID = pm.nextPathID
}
// previously unseen path, initiate path validation by sending a PATH_CHALLENGE
@@ -92,16 +123,18 @@ func (pm *pathManager) HandlePacket(
var pathChallengeData [8]byte
rand.Read(pathChallengeData[:])
p = &path{
id: pm.nextPathID,
addr: remoteAddr,
lastPacketTime: t,
rcvdNonProbing: isNonProbing,
pathChallenge: pathChallengeData,
}
pm.nextPathID++
pm.paths = append(pm.paths, p)
frames = append(frames, ackhandler.Frame{
Frame: &wire.PathChallengeFrame{Data: p.pathChallenge},
Handler: (*pathManagerAckHandler)(pm),
})
pm.paths[pm.nextPathID] = p
pm.nextPathID++
pm.logger.Debugf("enqueueing PATH_CHALLENGE for new path %s", remoteAddr)
}
if pathChallenge != nil {
@@ -127,14 +160,15 @@ func (pm *pathManager) HandlePathResponseFrame(f *wire.PathResponseFrame) {
// SwitchToPath is called when the connection switches to a new path
func (pm *pathManager) SwitchToPath(addr net.Addr) {
// retire all other paths
for id := range pm.paths {
if addrsEqual(pm.paths[id].addr, addr) {
pm.logger.Debugf("switching to path %d (%s)", id, addr)
for _, path := range pm.paths {
if addrsEqual(path.addr, addr) {
pm.logger.Debugf("switching to path %d (%s)", path.id, addr)
continue
}
pm.retireConnID(id)
pm.retireConnID(path.id)
}
clear(pm.paths)
pm.paths = pm.paths[:0]
}
type pathManagerAckHandler pathManager
@@ -147,10 +181,10 @@ func (pm *pathManagerAckHandler) OnAcked(f wire.Frame) {}
func (pm *pathManagerAckHandler) OnLost(f wire.Frame) {
// TODO: retransmit the packet the first time it is lost
pc := f.(*wire.PathChallengeFrame)
for id, path := range pm.paths {
for i, path := range pm.paths {
if path.pathChallenge == pc.Data {
delete(pm.paths, id)
pm.retireConnID(id)
pm.paths = slices.Delete(pm.paths, i, i+1)
pm.retireConnID(path.id)
break
}
}