diff --git a/Dockerfile b/Dockerfile index d0dfbe35..9b4578e2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -112,6 +112,7 @@ ENV VPN_SERVICE_PROVIDER=pia \ WIREGUARD_ADDRESSES_SECRETFILE=/run/secrets/wireguard_addresses \ WIREGUARD_MTU= \ WIREGUARD_IMPLEMENTATION=auto \ + WIREGUARD_GSO=on \ # Amnezia AMNEZIAWG_ENDPOINT_IP= \ AMNEZIAWG_ENDPOINT_PORT= \ diff --git a/internal/amneziawg/constructor_test.go b/internal/amneziawg/constructor_test.go index f799373e..fc80c320 100644 --- a/internal/amneziawg/constructor_test.go +++ b/internal/amneziawg/constructor_test.go @@ -62,6 +62,7 @@ func Test_New(t *testing.T) { MTU: device.DefaultMTU, IPv6: ptrTo(false), Implementation: "auto", + GSO: ptrTo(true), }, }, }, diff --git a/internal/amneziawg/log.go b/internal/amneziawg/log.go index 9ace7dce..e1673623 100644 --- a/internal/amneziawg/log.go +++ b/internal/amneziawg/log.go @@ -6,6 +6,7 @@ type Logger interface { Debug(s string) Debugf(format string, args ...interface{}) Info(s string) + Warn(s string) Error(s string) Errorf(format string, args ...interface{}) } diff --git a/internal/amneziawg/log_mock_test.go b/internal/amneziawg/log_mock_test.go index 4eca2a53..83b9d3af 100644 --- a/internal/amneziawg/log_mock_test.go +++ b/internal/amneziawg/log_mock_test.go @@ -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) +} diff --git a/internal/amneziawg/offload_linux.go b/internal/amneziawg/offload_linux.go new file mode 100644 index 00000000..e9db6f85 --- /dev/null +++ b/internal/amneziawg/offload_linux.go @@ -0,0 +1,27 @@ +package amneziawg + +import ( + "fmt" + + amneziatun "github.com/amnezia-vpn/amneziawg-go/tun" + "github.com/qdm12/gluetun/internal/wireguard" +) + +// createTUN creates a TUN device. When gso is false, IFF_VNET_HDR is +// omitted so amneziawg-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) (amneziatun.Device, error) { //nolint:ireturn + if gso { + return amneziatun.CreateTUN(name, mtu) + } + tunFile, err := wireguard.OpenTUNFile(name) + if err != nil { + return nil, fmt.Errorf("creating tun fd file: %w", err) + } + tunDevice, err := amneziatun.CreateTUNFromFile(tunFile, mtu) + if err != nil { + return nil, fmt.Errorf("creating TUN device from file: %w", err) + } + return tunDevice, nil +} diff --git a/internal/amneziawg/offload_unspecified.go b/internal/amneziawg/offload_unspecified.go new file mode 100644 index 00000000..a6aa8cbe --- /dev/null +++ b/internal/amneziawg/offload_unspecified.go @@ -0,0 +1,11 @@ +//go:build !linux + +package amneziawg + +import ( + amneziatun "github.com/amnezia-vpn/amneziawg-go/tun" +) + +func createTUN(name string, mtu int, _ bool) (amneziatun.Device, error) { //nolint:ireturn + return amneziatun.CreateTUN(name, mtu) +} diff --git a/internal/amneziawg/run.go b/internal/amneziawg/run.go index 5d703c27..1c44ffc2 100644 --- a/internal/amneziawg/run.go +++ b/internal/amneziawg/run.go @@ -8,7 +8,6 @@ import ( amneziaconn "github.com/amnezia-vpn/amneziawg-go/conn" amneziadevice "github.com/amnezia-vpn/amneziawg-go/device" - amneziatun "github.com/amnezia-vpn/amneziawg-go/tun" "github.com/qdm12/gluetun/internal/cleanup" "github.com/qdm12/gluetun/internal/wireguard" ) @@ -36,7 +35,7 @@ func setupUserspace(ctx context.Context, ) ( linkIndex uint32, waitAndCleanup func() error, err error, ) { - tun, err := amneziatun.CreateTUN(interfaceName, int(mtu)) + tun, err := createTUN(interfaceName, int(mtu), *settings.Wireguard.GSO) if err != nil { return 0, nil, fmt.Errorf("creating TUN device: %w", err) } diff --git a/internal/configuration/settings/wireguard.go b/internal/configuration/settings/wireguard.go index d4b44a41..e388c672 100644 --- a/internal/configuration/settings/wireguard.go +++ b/internal/configuration/settings/wireguard.go @@ -46,6 +46,13 @@ type Wireguard struct { // It defaults to "auto" and cannot be the empty string // in the internal state. Implementation string `json:"implementation"` + // GSO enables wireguard-go's GRO/GSO batched TUN I/O by creating + // the WireGuard TUN device with IFF_VNET_HDR. It should be disabled + // on kernels (e.g. certain NAS devices) that claim IFF_VNET_HDR + // support but return EINVAL when wireguard-go writes GRO-coalesced + // packets with virtio_net_hdr structs under load. + // It defaults to true and cannot be nil in the internal state. + GSO *bool `json:"gso"` } var regexpInterfaceName = regexp.MustCompile(`^[a-zA-Z0-9_]+$`) @@ -136,6 +143,7 @@ func (w *Wireguard) copy() (copied Wireguard) { Interface: w.Interface, MTU: w.MTU, Implementation: w.Implementation, + GSO: gosettings.CopyPointer(w.GSO), } } @@ -149,6 +157,7 @@ func (w *Wireguard) overrideWith(other Wireguard) { w.Interface = gosettings.OverrideWithComparable(w.Interface, other.Interface) w.MTU = gosettings.OverrideWithComparable(w.MTU, other.MTU) w.Implementation = gosettings.OverrideWithComparable(w.Implementation, other.Implementation) + w.GSO = gosettings.OverrideWithPointer(w.GSO, other.GSO) } func (w *Wireguard) setDefaults(vpnProvider string) { @@ -173,6 +182,7 @@ func (w *Wireguard) setDefaults(vpnProvider string) { w.Interface = gosettings.DefaultComparable(w.Interface, "wg0") w.MTU = gosettings.DefaultPointer(w.MTU, 0) w.Implementation = gosettings.DefaultComparable(w.Implementation, "auto") + w.GSO = gosettings.DefaultPointer(w.GSO, true) } func (w Wireguard) String() string { @@ -217,6 +227,10 @@ func (w Wireguard) toLinesNode() (node *gotree.Node) { node.Appendf("Implementation: %s", w.Implementation) } + if !*w.GSO { + node.Append("GSO disabled") + } + return node } @@ -265,5 +279,11 @@ func (w *Wireguard) read(r *reader.Reader, amneziaWG bool) (err error) { if err != nil { return err } + + w.GSO, err = r.BoolPtr("WIREGUARD_GSO") + if err != nil { + return err + } + return nil } diff --git a/internal/vpn/wireguard.go b/internal/vpn/wireguard.go index 2fe4b15c..c7250806 100644 --- a/internal/vpn/wireguard.go +++ b/internal/vpn/wireguard.go @@ -89,6 +89,8 @@ func buildWireguardSettings(connection models.Connection, } settings.PersistentKeepaliveInterval = *userSettings.PersistentKeepaliveInterval + gso := *userSettings.GSO + settings.GSO = &gso return settings } diff --git a/internal/vpn/wireguard_test.go b/internal/vpn/wireguard_test.go index 07417923..aa7bbae4 100644 --- a/internal/vpn/wireguard_test.go +++ b/internal/vpn/wireguard_test.go @@ -40,6 +40,7 @@ func Test_buildWireguardSettings(t *testing.T) { PersistentKeepaliveInterval: ptrTo(time.Hour), Interface: "wg1", MTU: ptrTo(uint32(1000)), + GSO: ptrTo(true), }, ipv6Supported: false, settings: wireguard.Settings{ @@ -58,6 +59,35 @@ func Test_buildWireguardSettings(t *testing.T) { RulePriority: 101, IPv6: ptrTo(false), MTU: 1000, + GSO: ptrTo(true), + }, + }, + "gso_disabled": { + connection: models.Connection{ + IP: netip.AddrFrom4([4]byte{5, 6, 7, 8}), + Port: 58820, + PubKey: "public", + }, + userSettings: settings.Wireguard{ + PrivateKey: ptrTo("private"), + PreSharedKey: ptrTo(""), + PersistentKeepaliveInterval: ptrTo(time.Duration(0)), + Interface: "wg0", + MTU: ptrTo(uint32(0)), + GSO: ptrTo(false), + }, + ipv6Supported: false, + settings: wireguard.Settings{ + InterfaceName: "wg0", + PrivateKey: "private", + PublicKey: "public", + Endpoint: netip.AddrPortFrom(netip.AddrFrom4([4]byte{5, 6, 7, 8}), 58820), + Addresses: []netip.Prefix{}, + AllowedIPs: []netip.Prefix{}, + RulePriority: 101, + IPv6: ptrTo(false), + MTU: 1320, + GSO: ptrTo(false), }, }, } diff --git a/internal/wireguard/constructor_test.go b/internal/wireguard/constructor_test.go index b80e64fc..ffaa35a2 100644 --- a/internal/wireguard/constructor_test.go +++ b/internal/wireguard/constructor_test.go @@ -56,6 +56,7 @@ func Test_New(t *testing.T) { MTU: device.DefaultMTU, IPv6: ptr(false), Implementation: "auto", + GSO: ptr(true), }, }, }, diff --git a/internal/wireguard/log.go b/internal/wireguard/log.go index 14a22e09..c7535304 100644 --- a/internal/wireguard/log.go +++ b/internal/wireguard/log.go @@ -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, } } diff --git a/internal/wireguard/log_mock_test.go b/internal/wireguard/log_mock_test.go index 4a90e161..cbce0037 100644 --- a/internal/wireguard/log_mock_test.go +++ b/internal/wireguard/log_mock_test.go @@ -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) +} diff --git a/internal/wireguard/log_test.go b/internal/wireguard/log_test.go index 548586cf..e47f9649 100644 --- a/internal/wireguard/log_test.go +++ b/internal/wireguard/log_test.go @@ -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) +} diff --git a/internal/wireguard/netlink_integration_test.go b/internal/wireguard/netlink_integration_test.go index d57806a1..22b662f9 100644 --- a/internal/wireguard/netlink_integration_test.go +++ b/internal/wireguard/netlink_integration_test.go @@ -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) {} diff --git a/internal/wireguard/offload_linux.go b/internal/wireguard/offload_linux.go new file mode 100644 index 00000000..3140193f --- /dev/null +++ b/internal/wireguard/offload_linux.go @@ -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 +} diff --git a/internal/wireguard/offload_unspecified.go b/internal/wireguard/offload_unspecified.go new file mode 100644 index 00000000..21704fd8 --- /dev/null +++ b/internal/wireguard/offload_unspecified.go @@ -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") +} diff --git a/internal/wireguard/run.go b/internal/wireguard/run.go index 5e6f238d..dbe244dd 100644 --- a/internal/wireguard/run.go +++ b/internal/wireguard/run.go @@ -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 { diff --git a/internal/wireguard/settings.go b/internal/wireguard/settings.go index 0d290ed6..13b47c95 100644 --- a/internal/wireguard/settings.go +++ b/internal/wireguard/settings.go @@ -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_]+$`) diff --git a/internal/wireguard/settings_test.go b/internal/wireguard/settings_test.go index 1ea48e98..5678e3bb 100644 --- a/internal/wireguard/settings_test.go +++ b/internal/wireguard/settings_test.go @@ -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), }, }, }