Skip to content

Refactor PacketProxy to PacketRelay (Side-by-Side)#618

Open
fortuna wants to merge 10 commits into
mainfrom
fortuna/packet-relay-2
Open

Refactor PacketProxy to PacketRelay (Side-by-Side)#618
fortuna wants to merge 10 commits into
mainfrom
fortuna/packet-relay-2

Conversation

@fortuna
Copy link
Copy Markdown
Contributor

@fortuna fortuna commented May 14, 2026

This PR introduces the new flow-based PacketRelay API side-by-side with the legacy PacketProxy API to avoid breaking existing consumers, migrating it to a dedicated standalone subpackage for perfect API isolation. It also modernizes lwip2transport to natively adopt the new abstractions.

Key Architectural Benefits:

  1. Flow-Based Push-Pull Design: Separates the send path (PacketSender) and receive path (PacketReceiver) explicitly. The receive path blocks and processes packets using the caller's own goroutine loop, eliminating "callback hell" and unnecessary background goroutines in the core components.
  2. Reusable Decorators: Extracted optional orthogonal behaviors (such as idle timeouts and lazy initialization) into clean, composable decorators (TimeoutPacketRelay, LazyPacketRelay) that can wrap any PacketRelay implementation.
  3. RFC-Compliant DoS Mitigation: Implements unidirectional refresh in TimeoutPacketRelay (only resetting the timer on outgoing SendPacket writes, as recommended by RFC 4787 Section 4.3) to prevent remote attackers from flooding the relay to hold mappings open indefinitely.
  4. Safely Decoupled Subpackage: The new abstraction resides in a dedicated package network/packetrelay complete with comprehensive package-level documentation, resolving all import cycles and maintaining zero dependencies on the legacy types.
  5. 100% Backwards Compatibility: Includes zero-buffering adapters (NewPacketProxyFromPacketRelay) and wrappers in the network package to allow existing consumers (such as lwip2transport) to easily adopt and bridge the new abstractions.

Core Component & Consumer Migrations:

  • PacketListenerRelay: Pure transport packet listener adapter with synchronized Close() mechanics safely unblocking active blocking reads.
  • TimeoutPacketRelay: Managed timeout decorator using a high-performance, race-free lastActivity timestamp to safely avoid standard AfterFunc/Reset races.
  • LazyPacketRelay [NEW]: Deferred association decorator that postpones underlying association creation until the first packet is sent, optimizing resource allocation.
  • DelegatePacketRelay: Thread-safe hot-swappable wrapper using atomic.Pointer to avoid autogenerated type consistency panic issues.
  • dnstruncate.PacketRelay: Refactored to channel-based PacketRelay design using a non-blocking lock-protected select{default:} queue to guarantee zero panics on channel closures during concurrent sends.
  • lwip2transport.ConfigureDeviceWithRelay [NEW]: Added native packetrelay support to the LwIP stack, eliminating response callbacks and using allocation-free net.UDPAddrFromAddrPort address conversions. Resolves a concurrent UDP session creation leak under mutex lock.

@fortuna fortuna requested a review from ohnorobo May 14, 2026 03:37
@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 14, 2026

Greptile Summary

This PR introduces the new packetrelay subpackage with its PacketRelay/PacketSender/PacketReceiver abstractions as a side-by-side addition to the legacy PacketProxy API, migrating dnstruncate and lwip2transport to the new flow-based design while preserving full backwards compatibility through adapters.

  • New network/packetrelay package adds PacketListenerRelay, TimeoutPacketRelay, LazyPacketRelay, and DelegatePacketRelay as composable decorators, with RFC 4787-aligned unidirectional timeout refresh and an atomic.Pointer-based hot-swap relay.
  • lwip2transport gains ConfigureDeviceWithRelay and a new udpRelayHandler that maps lwIP UDP connections to PacketRelay associations; session creation is serialised under a single mutex to prevent duplicate-session races.
  • dnstruncate is refactored to a channel-based PacketRelay with a bounded 16-slot queue and non-blocking sends, replacing the former callback-style PacketRequestSender/PacketResponseReceiver pattern.

Confidence Score: 3/5

The new PacketRelay abstraction layer is architecturally sound and well-tested, but two concrete defects warrant attention before the code is relied on in production: a broken interface contract and a global-lock bottleneck in the lwIP UDP path.

The PacketReceiver.ReceivePackets documentation promises a handler.Close() call that PacketHandler cannot receive — an unimplementable contract in the public API that misleads future consumers. More operationally, udpRelayHandler.ReceiveTo calls NewAssociation() while holding the single h.mu lock shared by every active session; with any relay whose association setup involves I/O, all in-flight UDP sessions stall until the new one is ready, which can cause measurable packet loss under real traffic.

network/packetrelay/packet_relay.go (interface contract) and network/lwip2transport/udp_relay.go (mutex scope during NewAssociation) are the two files that need the most attention before merging.

Important Files Changed

Filename Overview
network/packetrelay/packet_relay.go Defines the core PacketRelay/PacketSender/PacketReceiver/PacketHandler interfaces; the ReceivePackets doc comment references a non-existent handler.Close() method.
network/lwip2transport/udp_relay.go New UDP relay handler integrating lwIP with PacketRelay; NewAssociation() is called under h.mu, serialising all active UDP sessions during new session setup.
network/dnstruncate/packet_proxy.go Migrated to PacketRelay using channel-based design; queue-full error is an anonymous value (not inspectable) and mutex is held longer than necessary in SendPacket.
network/packetrelay/timeout_packet_relay.go Idle-timeout decorator using lastActivity timestamp; lock ordering and timer.Reset/Stop usage are correct and race-free.
network/packetrelay/lazy_packet_relay.go Deferred-association decorator; locking and cond-var wakeup mechanics are sound; goroutine spawned in waitInner is bounded by the inner relay's NewAssociation lifetime.
network/packetrelay/packet_listener_relay.go Clean PacketListenerRelay implementation; buffer-pool usage and addr-type handling are correct; Close correctly unblocks ReceivePackets via packetConn.Close().
network/packetrelay/delegate_packet_relay.go Hot-swappable relay wrapper using atomic.Pointer[PacketRelay] correctly avoids the atomic.Value type-consistency panic; thread-safe and well-tested.
network/packet_proxy.go Adapter bridging the legacy PacketProxy API to the new PacketRelay API; correctly translates error types and lifecycle events.
network/packet_listener_proxy.go Deprecated PacketListenerProxy now delegates to the new relay chain; backwards-compatible with existing timeout option.
network/lwip2transport/device.go Adds ConfigureDeviceWithRelay alongside the existing ConfigureDevice; udp field widened to lwip.UDPConnHandler interface for polymorphism.

Sequence Diagram

sequenceDiagram
    participant LwIP as lwIP Stack
    participant URH as udpRelayHandler
    participant PR as PacketRelay
    participant PS as PacketSender
    participant RCV as PacketReceiver
    participant FWD as udpRelayPacketForwarder

    LwIP->>URH: ReceiveTo(tunConn, data, dest)
    URH->>URH: lock h.mu
    alt new local address
        URH->>PR: NewAssociation()
        PR-->>URH: PacketSender, PacketReceiver
        URH->>URH: store sender in map
        URH->>URH: spawn goroutine for ReceivePackets
    end
    URH->>URH: unlock h.mu
    URH->>PS: SendPacket(data, dest)
    PS-->>LwIP: error or nil

    RCV->>FWD: HandlePacket(p, source)
    FWD->>LwIP: conn.WriteFrom(p, srcAddr)

    note over PS,RCV: On timeout or explicit Close
    PS->>PS: Close triggers inner.Close
    RCV-->>URH: ReceivePackets returns
    URH->>URH: closeSession removes from map
Loading

Reviews (1): Last reviewed commit: "Revert outline-cli" | Re-trigger Greptile

Comment on lines +68 to +70
//
// Before returning, ReceivePackets MUST call handler.Close() to indicate the end of the stream.
ReceivePackets(handler PacketHandler) error
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Unreachable contract: PacketHandler has no Close() method

The contract says "Before returning, ReceivePackets MUST call handler.Close() to indicate the end of the stream," but PacketHandler only defines HandlePacket. No implementation in this PR can satisfy this requirement, and any future implementor following the contract will get a compile error. If a cleanup signal is needed, either add Close() to PacketHandler or remove the sentence from the docstring.

Comment on lines +52 to +68
h.mu.Lock()
sender, ok := h.senders[laddr]
if !ok {
// Synchronize new session creation completely under the lock to prevent proxy resource leaks
// when concurrent packets arrive on a new local port.
var err error
sender, err = h.newSession(tunConn)
if err != nil {
h.mu.Unlock()
return err
}
h.senders[laddr] = sender
}
h.mu.Unlock()

return sender.SendPacket(data, destAddr.AddrPort())
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 NewAssociation() called while holding h.mu, blocking all active UDP sessions

newSession (which calls h.relay.NewAssociation()) runs entirely inside the h.mu lock. If the underlying relay's NewAssociation() involves I/O — such as PacketListenerRelay calling ListenPacket over a proxy connection — every concurrent ReceiveTo call for already-established sessions stalls waiting for the lock. A single slow handshake can drop or delay packets across all active UDP flows. The lock guards map access, but NewAssociation() itself only needs to happen once per new address; the map write is the only part that truly requires h.mu.

Comment on lines +170 to +177
// Push to channel inside the lock using select with default to avoid deadlocks
select {
case s.ch <- dnsPacket{payload: buf, source: destination}:
return nil
default:
// Queue is full!
return errors.New("DNS truncation queue full")
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Queue-full error is not a sentinel, making it impossible to detect with errors.Is

errors.New("DNS truncation queue full") creates a new, anonymous error value on every call. Callers that need to distinguish a full queue from network errors or a closed state cannot do so. Defining a package-level error variable lets callers use errors.Is for targeted handling.

Suggested change
// Push to channel inside the lock using select with default to avoid deadlocks
select {
case s.ch <- dnsPacket{payload: buf, source: destination}:
return nil
default:
// Queue is full!
return errors.New("DNS truncation queue full")
}
// Push to channel inside the lock using select with default to avoid deadlocks
select {
case s.ch <- dnsPacket{payload: buf, source: destination}:
return nil
default:
// Queue is full!
return ErrQueueFull
}

Comment on lines +141 to +178
// SendPacket implements [packetrelay.PacketSender].SendPacket(). It parses a packet from p, and determines whether it is
// a valid DNS request. If so, it will push a DNS response with TC (truncated) bit set to the receiver.
func (s *dnsTruncateSender) SendPacket(p []byte, destination netip.AddrPort) error {
s.mu.Lock()
defer s.mu.Unlock()

if s.closed {
return packetrelay.ErrClosed
}

if destination.Port() != standardDNSPort {
return 0, fmt.Errorf("UDP traffic to non-DNS port %v is not supported: %w", destination.Port(), network.ErrPortUnreachable)
return fmt.Errorf("UDP traffic to non-DNS port %v is not supported: %w", destination.Port(), network.ErrPortUnreachable)
}
if len(p) < dnsUdpMinMsgLen {
return 0, fmt.Errorf("invalid DNS message of length %v, it must be at least %v bytes", len(p), dnsUdpMinMsgLen)
return fmt.Errorf("invalid DNS message of length %v, it must be at least %v bytes", len(p), dnsUdpMinMsgLen)
}

// Allocate buffer from slicepool, because `go build -gcflags="-m"` shows a local array will escape to heap
slice := packetBufferPool.LazySlice()
buf := slice.Acquire()
defer slice.Release()

// We need to copy p into buf because "WriteTo must not modify p, even temporarily".
n := copy(buf, p)
// We need to copy p into a new buffer because we pass it through a channel
buf := make([]byte, len(p))
copy(buf, p)

// Set "Response", "Truncated" and "NoError"
// Note: gopacket is a good library doing this kind of things. But it will increase the binary size a lot.
// If we decide to use gopacket in the future, please evaluate the binary size and runtime memory consumption.
buf[dnsUdpAnswerByte] |= (dnsUdpResponseBit | dnsUdpTruncatedBit)
buf[dnsUdpRCodeByte] &= ^dnsUdpRCodeMask

// Copy QDCOUNT to ANCOUNT. This is an incorrect workaround for some DNS clients (such as Windows 7);
// because without these clients won't retry over TCP.
//
// For reference: https://github.com/eycorsican/go-tun2socks/blob/master/proxy/dnsfallback/udp.go#L59-L63
copy(buf[dnsARCntStartByte:dnsARCntEndByte+1], buf[dnsQDCntStartByte:dnsQDCntEndByte+1])

return h.respWriter.WriteFrom(buf[:n], net.UDPAddrFromAddrPort(destination))
// Push to channel inside the lock using select with default to avoid deadlocks
select {
case s.ch <- dnsPacket{payload: buf, source: destination}:
return nil
default:
// Queue is full!
return errors.New("DNS truncation queue full")
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 SendPacket holds s.mu during buffer allocation, copy, and header modification

The mutex is acquired before make([]byte, ...) + copy + bit manipulation, all of which are CPU-local work that needs no mutual exclusion. Only the closed check and the channel send require the lock. Holding it through the full body serialises all concurrent SendPacket calls unnecessarily.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant