feat(wireguard): WIREGUARD_GSO option to disable TUN vnet hdr batching (#3424)

This commit is contained in:
Tyler MacDonald
2026-08-04 21:31:22 -04:00
committed by GitHub
parent 297d6480d0
commit 0186f2ff4a
20 changed files with 260 additions and 11 deletions
+1
View File
@@ -56,6 +56,7 @@ func Test_New(t *testing.T) {
MTU: device.DefaultMTU,
IPv6: ptr(false),
Implementation: "auto",
GSO: ptr(true),
},
},
},
+26 -2
View File
@@ -1,6 +1,10 @@
package wireguard
import (
"fmt"
"strings"
"sync"
"golang.zx2c4.com/wireguard/device"
)
@@ -10,6 +14,7 @@ type Logger interface {
Debug(s string)
Debugf(format string, args ...interface{})
Info(s string)
Warn(s string)
Error(s string)
Erroer
}
@@ -18,9 +23,28 @@ type Erroer interface {
Errorf(format string, args ...any)
}
func makeDeviceLogger(logger Logger) (deviceLogger *device.Logger) {
func makeDeviceLogger(logger Logger, gso bool) (deviceLogger *device.Logger) {
errorf := logger.Errorf
if gso {
// Kernels advertising IFF_VNET_HDR support but rejecting
// GRO-coalesced writes make wireguard-go log this error for
// each failed write batch, see
// https://github.com/tailscale/tailscale/issues/13041
var suggestOnce sync.Once
errorf = func(format string, args ...any) {
logger.Errorf(format, args...)
message := fmt.Sprintf(format, args...)
if strings.Contains(message, "Failed to write packets to TUN device") &&
strings.Contains(message, "invalid argument") {
suggestOnce.Do(func() {
logger.Warn("The kernel seems to reject GRO-coalesced writes to " +
"the TUN device; consider setting WIREGUARD_GSO=off")
})
}
}
}
return &device.Logger{
Verbosef: logger.Debugf,
Errorf: logger.Errorf,
Errorf: errorf,
}
}
+12
View File
@@ -108,3 +108,15 @@ func (mr *MockLoggerMockRecorder) Info(s any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Info", reflect.TypeOf((*MockLogger)(nil).Info), s)
}
// Warn mocks base method.
func (m *MockLogger) Warn(s string) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "Warn", s)
}
// Warn indicates an expected call of Warn.
func (mr *MockLoggerMockRecorder) Warn(s any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Warn", reflect.TypeOf((*MockLogger)(nil).Warn), s)
}
+22 -1
View File
@@ -1,6 +1,7 @@
package wireguard
import (
"errors"
"testing"
"go.uber.org/mock/gomock"
@@ -13,7 +14,7 @@ func Test_makeDeviceLogger(t *testing.T) {
logger := NewMockLogger(ctrl)
deviceLogger := makeDeviceLogger(logger)
deviceLogger := makeDeviceLogger(logger, false)
logger.EXPECT().Debugf("test %d", 1)
deviceLogger.Verbosef("test %d", 1)
@@ -21,3 +22,23 @@ func Test_makeDeviceLogger(t *testing.T) {
logger.EXPECT().Errorf("test %d", 2)
deviceLogger.Errorf("test %d", 2)
}
func Test_makeDeviceLogger_gso_suggestion(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
logger := NewMockLogger(ctrl)
deviceLogger := makeDeviceLogger(logger, true)
const format = "Failed to write packets to TUN device: %v"
writeError := errors.New("write /dev/net/tun: invalid argument")
logger.EXPECT().Errorf(format, writeError).Times(2)
logger.EXPECT().Warn("The kernel seems to reject GRO-coalesced writes to " +
"the TUN device; consider setting WIREGUARD_GSO=off").Times(1)
deviceLogger.Errorf(format, writeError)
// The suggestion is only logged once for repeated errors.
deviceLogger.Errorf(format, writeError)
}
@@ -17,6 +17,7 @@ type noopDebugLogger struct{}
func (n noopDebugLogger) Debug(_ string) {}
func (n noopDebugLogger) Debugf(_ string, _ ...any) {}
func (n noopDebugLogger) Info(_ string) {}
func (n noopDebugLogger) Warn(_ string) {}
func (n noopDebugLogger) Error(_ string) {}
func (n noopDebugLogger) Errorf(_ string, _ ...any) {}
func (n noopDebugLogger) Patch(_ ...log.Option) {}
+53
View File
@@ -0,0 +1,53 @@
package wireguard
import (
"fmt"
"os"
"golang.org/x/sys/unix"
"golang.zx2c4.com/wireguard/tun"
)
// createTUN creates a TUN device. When gso is false, IFF_VNET_HDR is
// omitted so wireguard-go's initFromFlags sees no vnet header support and
// keeps tun.vnetHdr=false, falling back to simple single-packet writes instead
// of the GRO/GSO batch path that causes EINVAL on some vendor kernels.
func createTUN(name string, mtu int, gso bool) (tun.Device, error) { //nolint:ireturn
if gso {
return tun.CreateTUN(name, mtu)
}
tunFile, err := OpenTUNFile(name)
if err != nil {
return nil, fmt.Errorf("creating tun fd file: %w", err)
}
tunDevice, err := tun.CreateTUNFromFile(tunFile, mtu)
if err != nil {
return nil, fmt.Errorf("creating TUN device from file: %w", err)
}
return tunDevice, nil
}
// OpenTUNFile opens /dev/net/tun with IFF_TUN|IFF_NO_PI but without
// IFF_VNET_HDR. It is exported so that the amneziawg package can use the same
// file with amneziatun.CreateTUNFromFile.
func OpenTUNFile(name string) (*os.File, error) {
tunFD, err := unix.Open("/dev/net/tun", unix.O_RDWR|unix.O_CLOEXEC, 0)
if err != nil {
return nil, fmt.Errorf("opening /dev/net/tun: %w", err)
}
ifr, err := unix.NewIfreq(name)
if err != nil {
unix.Close(tunFD)
return nil, fmt.Errorf("creating ifreq: %w", err)
}
ifr.SetUint16(unix.IFF_TUN | unix.IFF_NO_PI) // intentionally omit IFF_VNET_HDR
if err := unix.IoctlIfreq(tunFD, unix.TUNSETIFF, ifr); err != nil {
unix.Close(tunFD)
return nil, fmt.Errorf("setting TUN flags: %w", err)
}
if err := unix.SetNonblock(tunFD, true); err != nil {
unix.Close(tunFD)
return nil, fmt.Errorf("setting nonblock: %w", err)
}
return os.NewFile(uintptr(tunFD), "/dev/net/tun"), nil
}
+17
View File
@@ -0,0 +1,17 @@
//go:build !linux
package wireguard
import (
"os"
"golang.zx2c4.com/wireguard/tun"
)
func createTUN(name string, mtu int, _ bool) (tun.Device, error) { //nolint:ireturn
return tun.CreateTUN(name, mtu)
}
func OpenTUNFile(_ string) (*os.File, error) {
panic("not implemented")
}
+6 -6
View File
@@ -11,7 +11,6 @@ import (
gtun "github.com/qdm12/gluetun/internal/tun"
"golang.zx2c4.com/wireguard/conn"
"golang.zx2c4.com/wireguard/device"
"golang.zx2c4.com/wireguard/tun"
"golang.zx2c4.com/wireguard/wgctrl"
)
@@ -62,7 +61,8 @@ func (w *Wireguard) Run(ctx context.Context, waitError chan<- error, ready chan<
linkIndex uint32, waitAndCleanup func() error, err error,
) {
return setupFunction(ctx,
w.settings.InterfaceName, w.netlink, w.settings.MTU, cleanups, w.logger)
w.settings.InterfaceName, w.netlink, w.settings.MTU,
*w.settings.GSO, cleanups, w.logger)
}
Run(ctx, waitError, ready, setup, w.settings, w.netlink, w.logger)
@@ -147,7 +147,7 @@ func Run(ctx context.Context, waitError chan<- error, ready chan<- struct{},
func setupKernelSpace(ctx context.Context,
interfaceName string, netLinker NetLinker, mtu uint32,
cleanups *cleanup.Cleanups, logger Logger) (
_ bool, cleanups *cleanup.Cleanups, logger Logger) (
linkIndex uint32, waitAndCleanup func() error, err error,
) {
links, err := netLinker.LinkList()
@@ -191,10 +191,10 @@ func setupKernelSpace(ctx context.Context,
func setupUserSpace(ctx context.Context,
interfaceName string, netLinker NetLinker, mtu uint32,
cleanups *cleanup.Cleanups, logger Logger) (
gso bool, cleanups *cleanup.Cleanups, logger Logger) (
linkIndex uint32, waitAndCleanup func() error, err error,
) {
tun, err := tun.CreateTUN(interfaceName, int(mtu))
tun, err := createTUN(interfaceName, int(mtu), gso)
if err != nil {
return 0, nil, fmt.Errorf("creating TUN device: %w", err)
}
@@ -220,7 +220,7 @@ func setupUserSpace(ctx context.Context,
cleanups.Add("closing bind", 7, bind.Close)
deviceLogger := makeDeviceLogger(logger)
deviceLogger := makeDeviceLogger(logger, gso)
device := device.NewDevice(tun, bind, deviceLogger)
cleanups.Add("closing Wireguard device", 6, func() error {
+12
View File
@@ -48,6 +48,13 @@ type Settings struct {
// Implementation is the implementation to use.
// It can be auto, kernelspace or userspace, and defaults to auto.
Implementation string
// GSO enables wireguard-go's GRO/GSO batch I/O path by creating
// the TUN device with IFF_VNET_HDR. When set to false, the TUN
// device is created without IFF_VNET_HDR so that wireguard-go
// falls back to single-packet reads and writes.
// It defaults to true and cannot be nil in the internal state.
// See WIREGUARD_GSO for details.
GSO *bool
}
func (s *Settings) SetDefaults() {
@@ -86,6 +93,11 @@ func (s *Settings) SetDefaults() {
const defaultImplementation = "auto"
s.Implementation = defaultImplementation
}
if s.GSO == nil {
gso := true
s.GSO = &gso
}
}
var interfaceNameRegexp = regexp.MustCompile(`^[a-zA-Z0-9_]+$`)
+4
View File
@@ -25,6 +25,7 @@ func Test_Settings_SetDefaults(t *testing.T) {
MTU: device.DefaultMTU,
IPv6: ptr(false),
Implementation: "auto",
GSO: ptr(true),
},
},
"default endpoint port": {
@@ -39,6 +40,7 @@ func Test_Settings_SetDefaults(t *testing.T) {
MTU: device.DefaultMTU,
IPv6: ptr(false),
Implementation: "auto",
GSO: ptr(true),
},
},
"not empty settings": {
@@ -50,6 +52,7 @@ func Test_Settings_SetDefaults(t *testing.T) {
MTU: device.DefaultMTU,
IPv6: ptr(true),
Implementation: "userspace",
GSO: ptr(false),
},
expected: Settings{
InterfaceName: "wg1",
@@ -59,6 +62,7 @@ func Test_Settings_SetDefaults(t *testing.T) {
MTU: device.DefaultMTU,
IPv6: ptr(true),
Implementation: "userspace",
GSO: ptr(false),
},
},
}