fix(tun): survive oversized packets without panic or stream teardown

A 20546-byte spoke datagram hit wbuf[:writeOffset+len(p)] (cap 16400)
and panicked the whole hub process. Three parts:

- tunDevice.Write rejects packets bigger than its buffers with the new
  exported ErrPacketTooLarge instead of reslicing past the end. The
  kernel would have refused the write anyway (tun MTU 1420), so nothing
  deliverable is lost.
- Buffers follow the device MTU (max 16KB): newTunDevice(ifce, mtu),
  so raising tun.mtu for legitimate jumbo UDP automatically makes such
  packets writable. The relay/KCP/stream path already passes 64KB.
- fromSpoke drops an oversized datagram with a windowed count and
  keeps the peer stream up; only a real device failure still tears
  the stream down.

Tests: tun write guard, normal passthrough, large-MTU accept, and
stream survival with rate-limited warnings; all written first and
watched fail.
This commit is contained in:
ginuerzh
2026-10-07 01:19:06 +00:00
parent 9ec8bdbfeb
commit a939758583
5 changed files with 220 additions and 11 deletions
+42
View File
@@ -13,6 +13,8 @@ import (
"github.com/songgao/water/waterutil"
"golang.org/x/net/ipv4"
"golang.org/x/net/ipv6"
listenertun "github.com/go-gost/x/listener/tun"
)
// ErrNoRoute is what deliver reports when a destination names no peer, or a
@@ -175,6 +177,13 @@ type p2pHub struct {
dropDst string
dropSince time.Time
// overMu guards the oversized-packet tally that countOversize keeps. Same
// shape as the unrouted tally above: the first miss is logged at once, the
// rest is summarized once per window, so a persistently oversized sender
// stays visible instead of becoming a silent blackhole.
overMu sync.Mutex
overCount int
overSince time.Time
}
func newP2PHub(device io.ReadWriter, router *peerRouter, ownNets []net.IPNet, warn func(string)) *p2pHub {
@@ -276,6 +285,32 @@ func (h *p2pHub) countUnrouted(dst net.IP) {
time.Since(since).Round(time.Millisecond).String())
}
// countOversize records one dropped oversized packet and reports the running
// total at most once per window.
func (h *p2pHub) countOversize(size int) {
h.overMu.Lock()
now := time.Now()
if h.overSince.IsZero() {
h.overSince = now
h.overCount = 1
h.overMu.Unlock()
h.warnf("oversized packet (%d bytes), discarded", size)
return
}
h.overCount++
// The window only restarts on a report, so a continuous flood reports once
// per window instead of once per packet.
if now.Sub(h.overSince) < unroutedWindow {
h.overMu.Unlock()
return
}
n, since := h.overCount, h.overSince
h.overSince, h.overCount = now, 0
h.overMu.Unlock()
h.warnf("oversized packets discarded: %d in %s", n,
time.Since(since).Round(time.Millisecond).String())
}
// fromSpoke handles one datagram from one peer: a keepalive is answered on the
// peer it arrived from, anything else is the device's.
func (h *p2pHub) fromSpoke(s *peerStream, pkt []byte) error {
@@ -287,6 +322,13 @@ func (h *p2pHub) fromSpoke(s *peerStream, pkt []byte) error {
defer h.wmu.Unlock()
_, err := h.device.Write(pkt)
if errors.Is(err, listenertun.ErrPacketTooLarge) {
// One undeliverable datagram, not a dead device: drop it, count it,
// and leave the stream up. Returning the error would tear the peer
// down over a sender-side anomaly and turn it into a reconnect storm.
h.countOversize(len(pkt))
return nil
}
return err
}
+37
View File
@@ -0,0 +1,37 @@
package tun
import (
"context"
"testing"
listenertun "github.com/go-gost/x/listener/tun"
)
// oversizeDevice rejects every write the way a real tun device rejects a
// packet bigger than its buffers.
type oversizeDevice struct{ nopDevice }
func (oversizeDevice) Write([]byte) (int, error) {
return 0, listenertun.ErrPacketTooLarge
}
// An oversized datagram is dropped and counted, but the peer's stream
// survives: killing the stream over one undeliverable packet turns a
// sender-side anomaly into a reconnect storm.
func TestP2POversizedDatagramKeepsStream(t *testing.T) {
var warns []string
hub := newP2PHub(oversizeDevice{}, newTestRouter(), nil,
func(s string) { warns = append(warns, s) })
_, peerEnd := newDatagramPipe(4)
s := newPeerStream(context.Background(), "peer-a", peerEnd)
pkt := make([]byte, 20546)
for i := 0; i < 2; i++ {
if err := hub.fromSpoke(s, pkt); err != nil {
t.Fatalf("fromSpoke #%d: %v, want nil (drop, don't kill the stream)", i, err)
}
}
if len(warns) != 1 {
t.Fatalf("warnings = %d, want 1 (first miss now, flood summarized per window)", len(warns))
}
}
+34 -10
View File
@@ -1,6 +1,7 @@
package tun
import (
"errors"
"io"
"golang.zx2c4.com/wireguard/tun"
@@ -10,6 +11,15 @@ const (
maxBufSize = 16 * 1024
)
// ErrPacketTooLarge is returned when a packet does not fit the device write
// buffer. Dropping it is the only sane option: the kernel would reject it
// anyway, and reslicing wbuf to fit would panic and take down the process.
//
// It is exported so the p2p hub can tell "one undeliverable datagram" apart
// from a dead device: the former is dropped and counted with the stream left
// up, the latter still tears the stream down.
var ErrPacketTooLarge = errors.New("tun: packet larger than device write buffer")
type tunDevice struct {
dev tun.Device
packets int
@@ -18,6 +28,10 @@ type tunDevice struct {
wbufs [][]byte
rbuf []byte
wbuf []byte
// maxPacket is the largest packet body the buffers hold. It follows the
// device MTU (floor maxBufSize): anything bigger is rejected in Write
// instead of panicking the reslice below.
maxPacket int
}
func (d *tunDevice) Read(p []byte) (n int, err error) {
@@ -56,6 +70,9 @@ func (d *tunDevice) Read(p []byte) (n int, err error) {
}
func (d *tunDevice) Write(p []byte) (n int, err error) {
if len(p) > d.maxPacket {
return 0, ErrPacketTooLarge
}
if writeOffset > 0 {
copy(d.wbuf[writeOffset:], p)
d.wbufs[0] = d.wbuf[:writeOffset+len(p)]
@@ -78,27 +95,34 @@ func (l *tunListener) createTunDevice() (dev io.ReadWriteCloser, name string, er
return
}
return newTunDevice(ifce)
return newTunDevice(ifce, l.md.config.MTU)
}
// newTunDevice adapts a tun device to the byte-stream conn this package hands
// out: the device reads and writes packets in batches, the listener speaks
// io.ReadWriteCloser.
func newTunDevice(ifce tun.Device) (dev io.ReadWriteCloser, name string, err error) {
// io.ReadWriteCloser. mtu sizes the packet buffers so a raised tun MTU (jumbo
// UDP) is writable; non-positive mtu keeps the default 16KB buffers.
func newTunDevice(ifce tun.Device, mtu int) (dev io.ReadWriteCloser, name string, err error) {
batchSize := ifce.BatchSize()
bufSize := maxBufSize
if mtu > bufSize {
bufSize = mtu
}
rbufs := make([][]byte, batchSize)
for i := 1; i < len(rbufs); i++ {
rbufs[i] = make([]byte, maxBufSize)
rbufs[i] = make([]byte, bufSize)
}
dev = &tunDevice{
dev: ifce,
sizes: make([]int, batchSize),
rbufs: rbufs,
wbufs: make([][]byte, 1),
rbuf: make([]byte, maxBufSize+readOffset),
wbuf: make([]byte, maxBufSize+writeOffset),
dev: ifce,
sizes: make([]int, batchSize),
rbufs: rbufs,
wbufs: make([][]byte, 1),
rbuf: make([]byte, bufSize+readOffset),
wbuf: make([]byte, bufSize+writeOffset),
maxPacket: bufSize,
}
name, err = ifce.Name()
+1 -1
View File
@@ -50,7 +50,7 @@ func (l *tunListener) createTun() (dev io.ReadWriteCloser, name string, ip net.I
return
}
dev, _, err = newTunDevice(ifce)
dev, _, err = newTunDevice(ifce, l.md.config.MTU)
if err != nil {
ifce.Close()
return
+106
View File
@@ -0,0 +1,106 @@
package tun
import (
"errors"
"os"
"testing"
"golang.zx2c4.com/wireguard/tun"
)
type fakeTunDevice struct {
wrote [][]byte
}
func (f *fakeTunDevice) File() *os.File { return nil }
func (f *fakeTunDevice) Read(bufs [][]byte, sizes []int, offset int) (int, error) {
return 0, nil
}
func (f *fakeTunDevice) Write(bufs [][]byte, offset int) (int, error) {
f.wrote = append(f.wrote, bufs[0][offset:])
return 1, nil
}
func (f *fakeTunDevice) MTU() (int, error) { return 1500, nil }
func (f *fakeTunDevice) Name() (string, error) { return "fake0", nil }
func (f *fakeTunDevice) Events() <-chan tun.Event { return nil }
func (f *fakeTunDevice) Close() error { return nil }
func (f *fakeTunDevice) BatchSize() int { return 1 }
// A packet that does not fit the device write buffer must be rejected with an
// error, never panic the process. Field case: a 20546-byte spoke packet hit
// wbuf[:writeOffset+len(p)] with cap 16400 and took down the whole hub.
func TestTunDeviceWriteOversizedPacket(t *testing.T) {
fake := &fakeTunDevice{}
dev, _, err := newTunDevice(fake, 0)
if err != nil {
t.Fatal(err)
}
defer dev.Close()
big := make([]byte, maxBufSize+1)
func() {
defer func() {
if r := recover(); r != nil {
t.Fatalf("Write panicked on %d-byte packet: %v", len(big), r)
}
}()
n, err := dev.Write(big)
if !errors.Is(err, ErrPacketTooLarge) {
t.Fatalf("Write err = %v, want %v", err, ErrPacketTooLarge)
}
if n != 0 {
t.Fatalf("Write n = %d, want 0", n)
}
}()
if len(fake.wrote) != 0 {
t.Fatalf("oversized packet reached the device, want it dropped")
}
}
// A device created with a large MTU must accept packets that the default
// 16KB buffer would reject. Field case: tun.mtu raised for jumbo UDP.
func TestTunDeviceWriteLargeMTUAcceptsJumbo(t *testing.T) {
fake := &fakeTunDevice{}
dev, _, err := newTunDevice(fake, 32768)
if err != nil {
t.Fatal(err)
}
defer dev.Close()
pkt := make([]byte, 20546)
n, err := dev.Write(pkt)
if err != nil {
t.Fatalf("Write err = %v, want nil", err)
}
if n != len(pkt) {
t.Fatalf("Write n = %d, want %d", n, len(pkt))
}
if len(fake.wrote) != 1 {
t.Fatalf("device writes = %d, want 1", len(fake.wrote))
}
}
// A normal-size packet must still pass through untouched.
func TestTunDeviceWriteNormalPacket(t *testing.T) {
fake := &fakeTunDevice{}
dev, _, err := newTunDevice(fake, 0)
if err != nil {
t.Fatal(err)
}
defer dev.Close()
pkt := make([]byte, 1500)
n, err := dev.Write(pkt)
if err != nil {
t.Fatalf("Write err = %v, want nil", err)
}
if n != len(pkt) {
t.Fatalf("Write n = %d, want %d", n, len(pkt))
}
if len(fake.wrote) != 1 {
t.Fatalf("device writes = %d, want 1", len(fake.wrote))
}
}