diff --git a/example.config.toml b/example.config.toml index 8dec1a8d4..846d31d32 100644 --- a/example.config.toml +++ b/example.config.toml @@ -242,6 +242,19 @@ handshake = "10s" # Only applies to Linux and Darwin; ignored on other platforms. # tcp-not-sent-lowat = "128kib" +# Small-segment ServerHello (Linux only). Some DPI boxes recognise the FakeTLS +# ServerHello when it is sent in full-size TCP segments, but not when it is +# split into small ones. client-mss splits only the ServerHello into segments +# as if the MSS were client-mss; the rest of the session uses client-mss-bulk, +# which is set as TCP_MAXSEG on the listening socket (Linux cannot raise the +# MSS of an established connection, so the order cannot be reversed). +# client-mss: 0 (default, disabled) or 48..1460. +# client-mss-bulk: 0 or 536..65495, 1400 by default when client-mss is set; +# 0 keeps the whole session at client-mss (client-mss must then be >= 88). +# Read at start only. +# client-mss = 92 +# client-mss-bulk = 1400 + # this defines a configuration for TCP keep alives. Default values are taken # from Golang default behavior. [network.keep-alive] diff --git a/internal/cli/client_mss.go b/internal/cli/client_mss.go new file mode 100644 index 000000000..5ad34d3dd --- /dev/null +++ b/internal/cli/client_mss.go @@ -0,0 +1,42 @@ +package cli + +import ( + "github.com/9seconds/mtg/v2/internal/config" + "github.com/9seconds/mtg/v2/mtglib" + "github.com/9seconds/mtg/v2/network" +) + +// clientMSSPlan maps [network] client-mss / client-mss-bulk onto two +// mechanisms: +// - listenerMSS is TCP_MAXSEG of the listening socket, it applies to the +// whole connection; +// - serverHelloMSS splits the ServerHello alone in user space. +// +// client-mss and bulk > 0: the socket gets bulk, the ServerHello is split by +// client-mss. client-mss and bulk = 0: the whole connection runs at +// client-mss (like iptables TCPMSS). client-mss = 0: nothing. Outside Linux +// the options are ignored with a warning. +func clientMSSPlan(conf *config.Config, logger mtglib.Logger) (serverHelloMSS, listenerMSS int) { + handshake, bulk := conf.GetClientMSS() + if handshake == 0 { + return 0, 0 + } + + if !network.ListenerMSSSupported { + logger.Warning("network.client-mss is supported only on Linux; ignored") + + return 0, 0 + } + + if bulk == 0 { + serverHelloMSS, listenerMSS = 0, int(handshake) + } else { + serverHelloMSS, listenerMSS = int(handshake), int(bulk) + } + + logger.BindInt("server_hello_mss", serverHelloMSS). + BindInt("listener_mss", listenerMSS). + Info("client MSS shaping is enabled") + + return serverHelloMSS, listenerMSS +} diff --git a/internal/cli/client_mss_test.go b/internal/cli/client_mss_test.go new file mode 100644 index 000000000..d6e7107fd --- /dev/null +++ b/internal/cli/client_mss_test.go @@ -0,0 +1,49 @@ +package cli + +import ( + "testing" + + "github.com/9seconds/mtg/v2/internal/config" + "github.com/9seconds/mtg/v2/logger" + "github.com/9seconds/mtg/v2/network" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestClientMSSPlan(t *testing.T) { + cases := []struct { + name string + network string + wantHello int + wantListener int + }{ + {name: "disabled", network: "", wantHello: 0, wantListener: 0}, + {name: "ServerHello at 92, session at 1400", network: "client-mss = 92\n", wantHello: 92, wantListener: 1400}, + {name: "custom bulk", network: "client-mss = 92\nclient-mss-bulk = 1200\n", wantHello: 92, wantListener: 1200}, + {name: "whole session at 92", network: "client-mss = 92\nclient-mss-bulk = 0\n", wantHello: 0, wantListener: 92}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + conf, err := config.Parse([]byte(`bind-to = "0.0.0.0:443" +secret = "7mqFMMq3P2Tvvt_rPx5qhmFnb29nbGUuY29t" + +[network] +` + tc.network)) + require.NoError(t, err) + require.NoError(t, conf.Validate()) + + hello, listener := clientMSSPlan(conf, logger.NewNoopLogger()) + + if !network.ListenerMSSSupported { + assert.Zero(t, hello) + assert.Zero(t, listener) + + return + } + + assert.Equal(t, tc.wantHello, hello) + assert.Equal(t, tc.wantListener, listener) + }) + } +} diff --git a/internal/cli/run_proxy.go b/internal/cli/run_proxy.go index f1ce8af8b..81830c4a9 100644 --- a/internal/cli/run_proxy.go +++ b/internal/cli/run_proxy.go @@ -301,6 +301,8 @@ func runProxy(conf *config.Config, version string) error { //nolint: funlen, cyc doppelGangerURLs[i] = v.String() } + serverHelloMSS, listenerMSS := clientMSSPlan(conf, logger) + opts := mtglib.ProxyOpts{ Logger: logger, Network: ntw, @@ -326,6 +328,7 @@ func runProxy(conf *config.Config, version string) error { //nolint: funlen, cyc DoppelGangerPerRaid: conf.Defense.Doppelganger.Repeats.Get(mtglib.DoppelGangerPerRaid), DoppelGangerEach: conf.Defense.Doppelganger.UpdateEach.Get(mtglib.DoppelGangerEach), DoppelGangerDRS: conf.Defense.Doppelganger.DRS.Get(false), + ServerHelloMSS: serverHelloMSS, } proxy, err := mtglib.NewProxy(opts) @@ -333,7 +336,7 @@ func runProxy(conf *config.Config, version string) error { //nolint: funlen, cyc return fmt.Errorf("cannot create a proxy: %w", err) } - listener, err := utils.NewListener(conf.BindTo.Get(""), 0) + listener, err := utils.NewListener(conf.BindTo.Get(""), 0, listenerMSS) if err != nil { return fmt.Errorf("cannot start proxy: %w", err) } diff --git a/internal/config/client_mss_test.go b/internal/config/client_mss_test.go new file mode 100644 index 000000000..c450c71ba --- /dev/null +++ b/internal/config/client_mss_test.go @@ -0,0 +1,85 @@ +package config_test + +import ( + "testing" + + "github.com/9seconds/mtg/v2/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func parseClientMSS(t *testing.T, network string) (*config.Config, error) { + t.Helper() + + conf, err := config.Parse([]byte(`bind-to = "0.0.0.0:443" +secret = "7mqFMMq3P2Tvvt_rPx5qhmFnb29nbGUuY29t" + +[network] +` + network)) + require.NoError(t, err) + + return conf, conf.Validate() +} + +func TestClientMSS(t *testing.T) { + cases := []struct { + name string + network string + wantHandshake uint + wantBulk uint + }{ + {name: "not set", network: "", wantHandshake: 0, wantBulk: 0}, + {name: "explicit zero", network: "client-mss = 0", wantHandshake: 0, wantBulk: 0}, + {name: "default bulk 1400", network: "client-mss = 92", wantHandshake: 92, wantBulk: 1400}, + {name: "custom bulk", network: "client-mss = 92\nclient-mss-bulk = 1200", wantHandshake: 92, wantBulk: 1200}, + {name: "bulk 0 keeps the whole session at client-mss", network: "client-mss = 92\nclient-mss-bulk = 0", wantHandshake: 92, wantBulk: 0}, + {name: "bulk without client-mss has no effect", network: "client-mss-bulk = 1400", wantHandshake: 0, wantBulk: 0}, + {name: "lower bound", network: "client-mss = 48", wantHandshake: 48, wantBulk: 1400}, + {name: "upper bounds", network: "client-mss = 1460\nclient-mss-bulk = 65495", wantHandshake: 1460, wantBulk: 65495}, + {name: "bulk lower bound", network: "client-mss = 92\nclient-mss-bulk = 536", wantHandshake: 92, wantBulk: 536}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + conf, err := parseClientMSS(t, tc.network) + require.NoError(t, err) + + handshake, bulk := conf.GetClientMSS() + assert.Equal(t, tc.wantHandshake, handshake) + assert.Equal(t, tc.wantBulk, bulk) + }) + } +} + +func TestClientMSSValidation(t *testing.T) { + cases := []struct { + name string + network string + wantErr string + }{ + {name: "client-mss below 48", network: "client-mss = 47", wantErr: "network.client-mss must be 0 or 48..1460"}, + {name: "client-mss above 1460", network: "client-mss = 1461", wantErr: "network.client-mss must be 0 or 48..1460"}, + {name: "bulk below 536", network: "client-mss = 92\nclient-mss-bulk = 535", wantErr: "network.client-mss-bulk must be 0 or 536..65495"}, + {name: "bulk above 65495", network: "client-mss = 92\nclient-mss-bulk = 65496", wantErr: "network.client-mss-bulk must be 0 or 536..65495"}, + {name: "bulk not greater than client-mss", network: "client-mss = 1400\nclient-mss-bulk = 1400", wantErr: "must be greater than network.client-mss"}, + {name: "bulk 0 and client-mss below the kernel minimum", network: "client-mss = 60\nclient-mss-bulk = 0", wantErr: "at least 88"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := parseClientMSS(t, tc.network) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + }) + } +} + +func TestClientMSSNotANumber(t *testing.T) { + _, err := config.Parse([]byte(`bind-to = "0.0.0.0:443" +secret = "7mqFMMq3P2Tvvt_rPx5qhmFnb29nbGUuY29t" + +[network] +client-mss = "small" +`)) + assert.Error(t, err) +} diff --git a/internal/config/config.go b/internal/config/config.go index e9a8e9625..65f32b8cc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -71,10 +71,16 @@ type Config struct { Interval TypeDuration `json:"interval"` Count TypeConcurrency `json:"count"` } `json:"keepAlive"` - DOHIP TypeIP `json:"dohIp"` - DNS TypeDNSURI `json:"dns"` - Proxies []TypeProxyURL `json:"proxies"` - TCPNotSentLowat TypeBytes `json:"tcpNotSentLowat"` + DOHIP TypeIP `json:"dohIp"` + DNS TypeDNSURI `json:"dns"` + Proxies []TypeProxyURL `json:"proxies"` + TCPNotSentLowat TypeBytes `json:"tcpNotSentLowat"` + // ClientMSS is the MSS the FakeTLS ServerHello is split by (0 disables). + ClientMSS TypeTCPMSS `json:"clientMss"` + // ClientMSSBulk is the MSS of the rest of the session; nil means the + // key is absent (default), an explicit 0 keeps the whole session at + // ClientMSS. + ClientMSSBulk *TypeTCPMSS `json:"clientMssBulk"` } `json:"network"` Stats struct { StatsD struct { @@ -135,9 +141,74 @@ func (c *Config) Validate() error { return fmt.Errorf("incorrect bind-to parameter %s", c.BindTo.String()) } + return c.validateClientMSS() +} + +// Bounds of client-mss and client-mss-bulk. client-mss goes up to 1460 (the +// Ethernet MSS); the ServerHello is split in user space, so values below the +// kernel minimum are fine too. But with client-mss-bulk = 0 client-mss is set +// on the socket itself, and the kernel rejects TCP_MAXSEG below TCP_MIN_MSS +// (88). bulk is no less than the classic 536 and no more than the kernel limit +// (MAX_TCP_WINDOW). +const ( + ClientMSSMin = 48 + ClientMSSMax = 1460 + ClientMSSKernelMin = 88 + ClientMSSBulkMin = 536 + ClientMSSBulkMax = 65495 + + // DefaultClientMSSBulk is the MSS of the session after the ServerHello + // when client-mss is set and client-mss-bulk is not. + DefaultClientMSSBulk = 1400 +) + +func (c *Config) validateClientMSS() error { + mss := c.Network.ClientMSS.Get(0) + if mss != 0 && (mss < ClientMSSMin || mss > ClientMSSMax) { + return fmt.Errorf("network.client-mss must be 0 or %d..%d, got %d", + ClientMSSMin, ClientMSSMax, mss) + } + + if c.Network.ClientMSSBulk == nil { + return nil + } + + bulk := c.Network.ClientMSSBulk.Get(0) + if bulk != 0 && (bulk < ClientMSSBulkMin || bulk > ClientMSSBulkMax) { + return fmt.Errorf("network.client-mss-bulk must be 0 or %d..%d, got %d", + ClientMSSBulkMin, ClientMSSBulkMax, bulk) + } + + if mss != 0 && bulk == 0 && mss < ClientMSSKernelMin { + return fmt.Errorf("network.client-mss must be at least %d when network.client-mss-bulk = 0, got %d", + ClientMSSKernelMin, mss) + } + + if mss != 0 && bulk != 0 && bulk <= mss { + return fmt.Errorf("network.client-mss-bulk (%d) must be greater than network.client-mss (%d)", + bulk, mss) + } + return nil } +// GetClientMSS returns the MSS for the ServerHello and the MSS of the rest of +// the session. client-mss = 0 disables everything (0, 0); client-mss-bulk +// without client-mss has no effect. bulk = 0 keeps the whole session at +// client-mss, as iptables TCPMSS on the client SYN would. +func (c *Config) GetClientMSS() (handshake, bulk uint) { + handshake = c.Network.ClientMSS.Get(0) + if handshake == 0 { + return 0, 0 + } + + if c.Network.ClientMSSBulk == nil { + return handshake, DefaultClientMSSBulk + } + + return handshake, c.Network.ClientMSSBulk.Get(0) +} + func (c *Config) String() string { buf := &bytes.Buffer{} encoder := json.NewEncoder(buf) diff --git a/internal/config/parse.go b/internal/config/parse.go index 97eb19103..3f5922f83 100644 --- a/internal/config/parse.go +++ b/internal/config/parse.go @@ -71,6 +71,10 @@ type tomlConfig struct { DNS string `toml:"dns" json:"dns,omitempty"` Proxies []string `toml:"proxies" json:"proxies,omitempty"` TCPNotSentLowat string `toml:"tcp-not-sent-lowat" json:"tcpNotSentLowat,omitempty"` + ClientMSS uint `toml:"client-mss" json:"clientMss,omitempty"` + // A pointer tells an explicit 0 ("do not raise the MSS after the + // ServerHello") from a missing key (default 1400). + ClientMSSBulk *uint `toml:"client-mss-bulk" json:"clientMssBulk,omitempty"` } `toml:"network" json:"network,omitempty"` Stats struct { StatsD struct { diff --git a/internal/config/type_tcp_mss.go b/internal/config/type_tcp_mss.go new file mode 100644 index 000000000..89f7a9c7b --- /dev/null +++ b/internal/config/type_tcp_mss.go @@ -0,0 +1,44 @@ +package config + +import ( + "fmt" + "strconv" +) + +// TypeTCPMSS is a TCP MSS in bytes. 0 means "not set / disabled"; the valid +// ranges of client-mss and client-mss-bulk differ and are checked by +// Config.Validate. +type TypeTCPMSS struct { + Value uint +} + +func (t *TypeTCPMSS) Set(value string) error { + mss, err := strconv.ParseUint(value, 10, 16) + if err != nil { + return fmt.Errorf("value is not uint16 (%s): %w", value, err) + } + + t.Value = uint(mss) + + return nil +} + +func (t TypeTCPMSS) Get(defaultValue uint) uint { + if t.Value == 0 { + return defaultValue + } + + return t.Value +} + +func (t *TypeTCPMSS) UnmarshalJSON(data []byte) error { + return t.Set(string(data)) +} + +func (t TypeTCPMSS) MarshalJSON() ([]byte, error) { + return []byte(t.String()), nil +} + +func (t TypeTCPMSS) String() string { + return strconv.FormatUint(uint64(t.Value), 10) +} diff --git a/internal/utils/net_listener.go b/internal/utils/net_listener.go index 59b1dc11b..f08ae121c 100644 --- a/internal/utils/net_listener.go +++ b/internal/utils/net_listener.go @@ -1,6 +1,7 @@ package utils import ( + "context" "fmt" "net" @@ -26,8 +27,13 @@ func (l Listener) Accept() (net.Conn, error) { return conn, nil } -func NewListener(bindTo string, bufferSize int) (net.Listener, error) { - base, err := net.Listen("tcp", bindTo) +// NewListener opens a listening socket. listenerMSS > 0 sets TCP_MAXSEG on it +// (Linux only): this MSS is advertised to the client in the SYN-ACK and caps +// the size of segments sent to the client for the whole connection. +func NewListener(bindTo string, bufferSize int, listenerMSS int) (net.Listener, error) { + lc := net.ListenConfig{Control: network.ListenControlMSS(listenerMSS)} + + base, err := lc.Listen(context.Background(), "tcp", bindTo) if err != nil { return nil, fmt.Errorf("cannot build a base listener: %w", err) } diff --git a/mtglib/proxy.go b/mtglib/proxy.go index 1ea6f2e63..c26ad95ed 100644 --- a/mtglib/proxy.go +++ b/mtglib/proxy.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "io" "net" "strconv" "sync" @@ -45,6 +46,10 @@ type Proxy struct { allowlist IPBlocklist eventStream EventStream logger Logger + + // serverHelloMSS > 0 splits the FakeTLS ServerHello (see + // server_hello_mss.go). + serverHelloMSS int } // DomainFrontingAddress returns a host:port pair for a fronting domain. @@ -211,7 +216,19 @@ func (p *Proxy) doFakeTLSHandshake(ctx *streamContext) bool { gangerNoise := p.doppelGanger.NoiseParams() noiseParams := fake.NoiseParams{Mean: gangerNoise.Mean, Jitter: gangerNoise.Jitter} - if err := fake.SendServerHello(ctx.clientConn, p.secret.Key[:], clientHello, noiseParams); err != nil { + // The ServerHello is the only server response in the FakeTLS handshake, + // and that is what DPI looks at. With client-mss it leaves in small + // segments; the rest of the session is not affected. + var helloWriter io.Writer = ctx.clientConn + if p.serverHelloMSS > 0 { + helloWriter = fragmentedWriter{ + conn: ctx.clientConn, + mss: p.serverHelloMSS, + deadline: time.Now().Add(p.handshakeTimeout), + } + } + + if err := fake.SendServerHello(helloWriter, p.secret.Key[:], clientHello, noiseParams); err != nil { p.logger.InfoError("cannot send welcome packet", err) return false } @@ -363,6 +380,7 @@ func NewProxy(opts ProxyOpts) (*Proxy, error) { tolerateTimeSkewness: opts.getTolerateTimeSkewness(), idleTimeout: opts.getIdleTimeout(), handshakeTimeout: opts.getHandshakeTimeout(), + serverHelloMSS: opts.ServerHelloMSS, allowFallbackOnUnknownDC: opts.AllowFallbackOnUnknownDC, telegram: tg, doppelGanger: doppel.NewGanger( diff --git a/mtglib/proxy_client_mss_linux_test.go b/mtglib/proxy_client_mss_linux_test.go new file mode 100644 index 000000000..a94986db2 --- /dev/null +++ b/mtglib/proxy_client_mss_linux_test.go @@ -0,0 +1,186 @@ +//go:build linux + +package mtglib_test + +import ( + "context" + "encoding/base64" + "encoding/binary" + "encoding/json" + "io" + "net" + "os" + "path/filepath" + "testing" + "time" + + "github.com/9seconds/mtg/v2/antireplay" + "github.com/9seconds/mtg/v2/ipblocklist" + "github.com/9seconds/mtg/v2/ipblocklist/files" + "github.com/9seconds/mtg/v2/logger" + "github.com/9seconds/mtg/v2/mtglib" + "github.com/9seconds/mtg/v2/network" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/yl2chen/cidranger" + "golang.org/x/sys/unix" +) + +// The secret that signed the real ClientHello snapshots in testdata of the +// fake package. +const snapshotSecret = "ee367a189aee18fa31c190054efd4a8e9573746f726167652e676f6f676c65617069732e636f6d" + +func snapshotClientHello(t *testing.T) []byte { + t.Helper() + + data, err := os.ReadFile(filepath.Join( + "internal", "tls", "fake", "testdata", "client-hello-ok-19dfe38384b9884b.json")) + require.NoError(t, err) + + snapshot := struct { + Full string `json:"full"` + }{} + require.NoError(t, json.Unmarshal(data, &snapshot)) + + full, err := base64.StdEncoding.DecodeString(snapshot.Full) + require.NoError(t, err) + + return full +} + +// serverHelloSegments runs a real FakeTLS handshake through the proxy with +// the given ServerHelloMSS and returns how many segments the client received +// while reading the ServerHello (tcpi_segs_in), and the ServerHello size. +func serverHelloSegments(t *testing.T, serverHelloMSS int) (int, int) { + t.Helper() + + secret, err := mtglib.ParseSecret(snapshotSecret) + require.NoError(t, err) + + dialer, err := network.NewDefaultDialer(0, 0) + require.NoError(t, err) + + ntw, err := network.NewNetwork(dialer, "mtgtest", "1.1.1.1", 0) + require.NoError(t, err) + + allowlist, err := ipblocklist.NewFireholFromFiles(logger.NewNoopLogger(), 1, + []files.File{files.NewMem([]*net.IPNet{cidranger.AllIPv4, cidranger.AllIPv6})}, nil) + require.NoError(t, err) + + go allowlist.Run(time.Second) + + require.Eventually(t, func() bool { + return allowlist.Contains(net.ParseIP("127.0.0.1")) + }, 2*time.Second, 10*time.Millisecond) + + finished := streamFinished(make(chan struct{}, 1)) + + proxy, err := mtglib.NewProxy(mtglib.ProxyOpts{ + Secret: secret, + Network: ntw, + AntiReplayCache: antireplay.NewNoop(), + IPBlocklist: ipblocklist.NewNoop(), + IPAllowlist: allowlist, + EventStream: finished, + Logger: logger.NewNoopLogger(), + UseTestDCs: true, + // The ClientHello snapshot was taken in 2021. + TolerateTimeSkewness: 100 * 365 * 24 * time.Hour, + ServerHelloMSS: serverHelloMSS, + }) + require.NoError(t, err) + + defer proxy.Shutdown() + + listener, err := net.Listen("tcp4", "127.0.0.1:0") + require.NoError(t, err) + + defer listener.Close() //nolint: errcheck + + go proxy.Serve(listener) //nolint: errcheck + + conn, err := net.Dial("tcp4", listener.Addr().String()) + require.NoError(t, err) + + // Let the stream finish before Shutdown: the proxy closes active streams + // on Shutdown, which must not overlap with the handshake still going on. + defer func() { + conn.Close() //nolint: errcheck + + select { + case <-finished: + case <-time.After(5 * time.Second): + t.Error("the stream has not finished") + } + }() + + client := conn.(*net.TCPConn) //nolint: forcetypeassert + require.NoError(t, client.SetDeadline(time.Now().Add(5*time.Second))) + + before := clientTCPInfo(t, client) + + _, err = client.Write(snapshotClientHello(t)) + require.NoError(t, err) + + total := 0 + + for range 3 { // ServerHello, ChangeCipherSpec, ApplicationData + header := make([]byte, 5) + _, err := io.ReadFull(client, header) + require.NoError(t, err) + + body := make([]byte, binary.BigEndian.Uint16(header[3:])) + _, err = io.ReadFull(client, body) + require.NoError(t, err) + + total += len(header) + len(body) + } + + after := clientTCPInfo(t, client) + + return int(after.Segs_in - before.Segs_in), total +} + +func clientTCPInfo(t *testing.T, conn *net.TCPConn) *unix.TCPInfo { + t.Helper() + + rawConn, err := conn.SyscallConn() + require.NoError(t, err) + + var ( + info *unix.TCPInfo + sysErr error + ) + + require.NoError(t, rawConn.Control(func(fd uintptr) { + info, sysErr = unix.GetsockoptTCPInfo(int(fd), unix.IPPROTO_TCP, unix.TCP_INFO) + })) + require.NoError(t, sysErr) + + return info +} + +// With ServerHelloMSS the proxy sends the ServerHello in small segments (80 +// bytes of payload here), without it - in a few large ones. +func TestProxyServerHelloMSS(t *testing.T) { + plainSegs, plainSize := serverHelloSegments(t, 0) + shapedSegs, shapedSize := serverHelloSegments(t, 92) + + assert.LessOrEqual(t, plainSegs, plainSize/1000+3) + assert.GreaterOrEqual(t, shapedSegs, shapedSize/80) + + t.Logf("ServerHello without client-mss: %d bytes, %d segments; with client-mss 92: %d bytes, %d segments", + plainSize, plainSegs, shapedSize, shapedSegs) +} + +// streamFinished is an EventStream that reports the end of a stream. +type streamFinished chan struct{} + +func (s streamFinished) Send(_ context.Context, evt mtglib.Event) { + if _, ok := evt.(mtglib.EventFinish); ok { + select { + case s <- struct{}{}: + default: + } + } +} diff --git a/mtglib/proxy_opts.go b/mtglib/proxy_opts.go index 41ffa84e0..8c9435dd0 100644 --- a/mtglib/proxy_opts.go +++ b/mtglib/proxy_opts.go @@ -177,6 +177,13 @@ type ProxyOpts struct { // DoppelGangerDRS defines if TLS Dynamic Record Sizing is active. DoppelGangerDRS bool + + // ServerHelloMSS > 0 sends the FakeTLS ServerHello to the client in + // segments with a payload as at MSS = ServerHelloMSS (Linux only); the rest + // of the connection uses the regular MSS. 0 disables it. + // + // This is an optional setting. + ServerHelloMSS int } func (p ProxyOpts) valid() error { diff --git a/mtglib/server_hello_mss.go b/mtglib/server_hello_mss.go new file mode 100644 index 000000000..d9c350c93 --- /dev/null +++ b/mtglib/server_hello_mss.go @@ -0,0 +1,41 @@ +package mtglib + +import ( + "net" + "time" +) + +// Small-segment FakeTLS ServerHello (network.client-mss). +// +// Some DPI boxes recognise the FakeTLS ServerHello when it arrives in +// full-size TCP segments, but not when it is split into small ones (as with a +// client MSS of 92). Keeping a small MSS for the whole session is slow and +// costs a lot of packets per second, so only the ServerHello is split; the +// rest of the session uses full-size segments. +// +// Why not "a small TCP_MAXSEG on the listener, then setsockopt a larger one": +// Linux fixes mss_clamp during the TCP handshake as min(client MSS, socket +// TCP_MAXSEG), and setsockopt(TCP_MAXSEG) on an established connection only +// changes user_mss, which no longer affects the size of outgoing segments +// (checked on 6.12: listener MSS 92, then setsockopt 1400 - tcpi_snd_mss stays +// at 80). For the same reason the MSS of an established connection cannot be +// lowered temporarily either. +// +// So the ServerHello is written in chunks that fit one segment at client-mss, +// and each next chunk is written only after the previous one has left the +// socket queue (tcpi_notsent_bytes = 0). Otherwise chunks that hit the +// congestion window or autocorking are merged by the kernel into one large +// segment: at 100 ms RTT 4 KB written in 80-byte chunks without waiting leave +// in 13 segments, with waiting - in 52. + +// fragmentedWriter writes the ServerHello in chunks of mss (see +// writeFragmented). +type fragmentedWriter struct { + conn net.Conn + mss int + deadline time.Time +} + +func (w fragmentedWriter) Write(p []byte) (int, error) { + return writeFragmented(w.conn, p, w.mss, w.deadline) +} diff --git a/mtglib/server_hello_mss_linux.go b/mtglib/server_hello_mss_linux.go new file mode 100644 index 000000000..a73f708c7 --- /dev/null +++ b/mtglib/server_hello_mss_linux.go @@ -0,0 +1,164 @@ +//go:build linux + +package mtglib + +import ( + "fmt" + "net" + "os" + "time" + + "golang.org/x/sys/unix" +) + +// tcpTimestampsOverhead is the size of the TCP timestamps option (with +// padding): the segment payload is that much smaller than the MSS when +// timestamps are negotiated. +const tcpTimestampsOverhead = 12 + +// tcpiOptTimestamps is TCPI_OPT_TIMESTAMPS of tcpi_options (linux/tcp.h); +// golang.org/x/sys/unix does not define it. +const tcpiOptTimestamps = 1 + +// serverHelloWaitMax caps the wait for one ServerHello to be sent when no +// handshake deadline is given. +const serverHelloWaitMax = 10 * time.Second + +const ( + notsentPollMin = 100 * time.Microsecond + notsentPollMax = 5 * time.Millisecond +) + +// writeFragmented writes data to a TCP connection in chunks, each of which +// leaves in its own segment with a payload no larger than at MSS = mss. If the +// connection is not TCP or mss <= 0, data is written in one go. +func writeFragmented(conn net.Conn, data []byte, mss int, deadline time.Time) (int, error) { + tcp := tcpConnOf(conn) + if tcp == nil || mss <= 0 { + return conn.Write(data) //nolint: wrapcheck + } + + rawConn, err := tcp.SyscallConn() + if err != nil { + return 0, fmt.Errorf("cannot get raw connection: %w", err) + } + + // Every chunk must leave immediately, without Nagle. Go enables + // TCP_NODELAY by default, but do not rely on it. + if err := tcp.SetNoDelay(true); err != nil { + return 0, fmt.Errorf("cannot set TCP_NODELAY: %w", err) + } + + info, err := getTCPInfo(rawConn) + if err != nil { + return 0, err + } + + chunk := segmentPayload(mss, info) + + if deadline.IsZero() { + deadline = time.Now().Add(serverHelloWaitMax) + } + + written := 0 + + for written < len(data) { + if err := waitNotSent(rawConn, deadline); err != nil { + return written, err + } + + end := min(written+chunk, len(data)) + + n, err := tcp.Write(data[written:end]) + written += n + + if err != nil { + return written, err //nolint: wrapcheck + } + } + + // Wait for the last chunk as well, otherwise the next write may be glued + // to it. + return written, waitNotSent(rawConn, deadline) +} + +// segmentPayload is the segment payload at MSS = mss: minus the timestamps +// option if it is negotiated, and no more than the current MSS of the +// connection. +func segmentPayload(mss int, info *unix.TCPInfo) int { + payload := mss + if info.Options&tcpiOptTimestamps != 0 { + payload -= tcpTimestampsOverhead + } + + if sndMSS := int(info.Snd_mss); sndMSS > 0 && payload > sndMSS { + payload = sndMSS + } + + return max(payload, 1) +} + +type rawControl interface { + Control(f func(fd uintptr)) error +} + +func getTCPInfo(rawConn rawControl) (*unix.TCPInfo, error) { + var ( + info *unix.TCPInfo + sysErr error + ) + + if err := rawConn.Control(func(fd uintptr) { + info, sysErr = unix.GetsockoptTCPInfo(int(fd), unix.IPPROTO_TCP, unix.TCP_INFO) + }); err != nil { + return nil, fmt.Errorf("cannot access socket: %w", err) + } + + if sysErr != nil { + return nil, fmt.Errorf("cannot get TCP_INFO: %w", sysErr) + } + + return info, nil +} + +// waitNotSent waits until the socket queue has no unsent bytes (bytes already +// sent but not yet acknowledged do not count). It polls with a growing pause: +// waiting is needed only when the congestion window is full, which costs a +// few RTTs per handshake. +func waitNotSent(rawConn rawControl, deadline time.Time) error { + pause := notsentPollMin + + for { + info, err := getTCPInfo(rawConn) + if err != nil { + return err + } + + if info.Notsent_bytes == 0 { + return nil + } + + if !time.Now().Before(deadline) { + return fmt.Errorf("server hello is not sent in time: %w", os.ErrDeadlineExceeded) + } + + time.Sleep(pause) + + pause = min(pause*2, notsentPollMax) + } +} + +// tcpConnOf returns the underlying TCP connection of the client: directly or +// from under the PROXY protocol wrapper. nil means it is not TCP. +func tcpConnOf(conn net.Conn) *net.TCPConn { + switch c := conn.(type) { + case *net.TCPConn: + return c + case interface{ TCPConn() (*net.TCPConn, bool) }: + if tcp, ok := c.TCPConn(); ok { + return tcp + } + } + + return nil +} diff --git a/mtglib/server_hello_mss_linux_test.go b/mtglib/server_hello_mss_linux_test.go new file mode 100644 index 000000000..1cef1f3c5 --- /dev/null +++ b/mtglib/server_hello_mss_linux_test.go @@ -0,0 +1,302 @@ +//go:build linux + +package mtglib + +import ( + "bufio" + "bytes" + "crypto/rand" + "io" + "net" + "syscall" + "testing" + "time" + + "github.com/9seconds/mtg/v2/mtglib/internal/tls" + "github.com/9seconds/mtg/v2/mtglib/internal/tls/fake" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/sys/unix" +) + +// mssTestPair opens a listener with TCP_MAXSEG = listenerMSS (0 leaves it +// alone) and returns the accepted server and the client connections. +// clientRcvBuf > 0 sets SO_RCVBUF of the client before connect (a small +// receive window). +func mssTestPair(t *testing.T, listenerMSS, clientRcvBuf int) (*net.TCPConn, *net.TCPConn) { + t.Helper() + + // network.ListenControlMSS cannot be imported from here (import cycle); + // it is tested in the network package, here the same is done directly. + lc := net.ListenConfig{Control: func(_, _ string, conn syscall.RawConn) error { + if listenerMSS <= 0 { + return nil + } + + var opErr error + + if err := conn.Control(func(fd uintptr) { + opErr = unix.SetsockoptInt(int(fd), unix.IPPROTO_TCP, unix.TCP_MAXSEG, listenerMSS) + }); err != nil { + return err + } + + return opErr + }} + + ln, err := lc.Listen(t.Context(), "tcp4", "127.0.0.1:0") + require.NoError(t, err) + + t.Cleanup(func() { ln.Close() }) //nolint: errcheck + + dialer := net.Dialer{} + if clientRcvBuf > 0 { + dialer.Control = func(_, _ string, conn syscall.RawConn) error { + var opErr error + + err := conn.Control(func(fd uintptr) { + opErr = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_RCVBUF, clientRcvBuf) + }) + if err != nil { + return err + } + + return opErr + } + } + + client, err := dialer.DialContext(t.Context(), "tcp4", ln.Addr().String()) + require.NoError(t, err) + + t.Cleanup(func() { client.Close() }) //nolint: errcheck + + server, err := ln.Accept() + require.NoError(t, err) + + t.Cleanup(func() { server.Close() }) //nolint: errcheck + + return server.(*net.TCPConn), client.(*net.TCPConn) //nolint: forcetypeassert +} + +func tcpInfoOf(t *testing.T, conn *net.TCPConn) *unix.TCPInfo { + t.Helper() + + rawConn, err := conn.SyscallConn() + require.NoError(t, err) + + info, err := getTCPInfo(rawConn) + require.NoError(t, err) + + return info +} + +// waitAllAcked waits until everything the server sent is acknowledged, so +// that tcpi_segs_out no longer changes. +func waitAllAcked(t *testing.T, conn *net.TCPConn) *unix.TCPInfo { + t.Helper() + + var info *unix.TCPInfo + + require.Eventually(t, func() bool { + info = tcpInfoOf(t, conn) + + return info.Notsent_bytes == 0 && info.Unacked == 0 + }, 5*time.Second, time.Millisecond) + + return info +} + +func readAllAsync(conn net.Conn, size int) <-chan []byte { + done := make(chan []byte, 1) + + go func() { + buf := make([]byte, size) + n, _ := io.ReadFull(conn, buf) + done <- buf[:n] + }() + + return done +} + +// The main scenario: the socket is at bulk 1400, the ServerHello (~4 KB) is +// split by 92 - at least one segment per chunk, and the data after it goes in +// large segments (tcpi_snd_mss >= 1000). +func TestWriteFragmentedSmallHelloThenBulk(t *testing.T) { + server, client := mssTestPair(t, 1400, 0) + + clientMSS, err := getsockoptMSS(client) + require.NoError(t, err) + assert.LessOrEqual(t, clientMSS, 1400) + assert.GreaterOrEqual(t, clientMSS, 1000) + + hello := make([]byte, 4096) + _, err = rand.Read(hello) + require.NoError(t, err) + + got := readAllAsync(client, len(hello)) + + before := waitAllAcked(t, server) + chunk := segmentPayload(92, before) + assert.LessOrEqual(t, chunk, 80+12) + + n, err := writeFragmented(server, hello, 92, time.Now().Add(5*time.Second)) + require.NoError(t, err) + assert.Equal(t, len(hello), n) + + afterHello := waitAllAcked(t, server) + helloSegs := int(afterHello.Segs_out - before.Segs_out) + wantSegs := (len(hello) + chunk - 1) / chunk + + assert.GreaterOrEqual(t, helloSegs, wantSegs, "the ServerHello must leave in chunks of %d bytes", chunk) + assert.Equal(t, hello, <-got) + + bulk := make([]byte, 64*1024) + got = readAllAsync(client, len(bulk)) + + _, err = server.Write(bulk) + require.NoError(t, err) + + afterBulk := waitAllAcked(t, server) + bulkSegs := int(afterBulk.Segs_out - afterHello.Segs_out) + + assert.GreaterOrEqual(t, int(afterBulk.Snd_mss), 1000) + assert.Less(t, bulkSegs, len(bulk)/500, "segments after the ServerHello must be large") + assert.Len(t, <-got, len(bulk)) + + t.Logf("ServerHello %d bytes: chunk %d, segments %d (want >= %d); "+ + "bulk 64 KB: segments %d, tcpi_snd_mss before=%d after=%d", + len(hello), chunk, helloSegs, wantSegs, bulkSegs, before.Snd_mss, afterBulk.Snd_mss) +} + +// Chunks must not be merged when sending is window-limited: a client with a +// tiny receive window does not read for 300 ms, the kernel keeps the data +// queued. Without waiting for tcpi_notsent_bytes = 0 the next chunks would be +// appended to the unsent tail of the queue and leave as one large segment. +func TestWriteFragmentedDoesNotCoalesceWhenWindowLimited(t *testing.T) { + server, client := mssTestPair(t, 1400, 2048) + + hello := make([]byte, 4096) + _, err := rand.Read(hello) + require.NoError(t, err) + + before := waitAllAcked(t, server) + chunk := segmentPayload(92, before) + + got := make(chan []byte, 1) + + go func() { + time.Sleep(300 * time.Millisecond) + + buf := make([]byte, len(hello)) + n, _ := io.ReadFull(client, buf) + got <- buf[:n] + }() + + _, err = writeFragmented(server, hello, 92, time.Now().Add(5*time.Second)) + require.NoError(t, err) + + assert.Equal(t, hello, <-got) + + after := waitAllAcked(t, server) + segs := int(after.Segs_out - before.Segs_out) + wantSegs := (len(hello) + chunk - 1) / chunk + + assert.GreaterOrEqual(t, segs, wantSegs) + t.Logf("receive window 2 KB: ServerHello of %d bytes left in %d segments (%d chunks)", len(hello), segs, wantSegs) +} + +// A real ServerHello written through fragmentedWriter is parsed by the client +// as three TLS records: splitting does not corrupt the stream. +func TestSendServerHelloThroughFragmentedWriter(t *testing.T) { + server, client := mssTestPair(t, 1400, 0) + + hello := &fake.ClientHello{CipherSuite: 4867, SessionID: make([]byte, 32)} + _, err := rand.Read(hello.SessionID) + require.NoError(t, err) + + secret := GenerateSecret("example.com") + writer := fragmentedWriter{conn: server, mss: 92, deadline: time.Now().Add(5 * time.Second)} + + before := waitAllAcked(t, server) + + errCh := make(chan error, 1) + + go func() { + errCh <- fake.SendServerHello(writer, secret.Key[:], hello, fake.NoiseParams{}) + }() + + reader := bufio.NewReader(client) + total := 0 + + for _, want := range []byte{tls.TypeHandshake, tls.TypeChangeCipherSpec, tls.TypeApplicationData} { + rec := &bytes.Buffer{} + + recordType, length, err := tls.ReadRecord(reader, rec) + require.NoError(t, err) + assert.Equal(t, want, recordType) + + total += 5 + int(length) + } + + require.NoError(t, <-errCh) + + after := waitAllAcked(t, server) + chunk := segmentPayload(92, before) + + assert.GreaterOrEqual(t, int(after.Segs_out-before.Segs_out), (total+chunk-1)/chunk) +} + +// The PROXY protocol wrapper (TCPConn()) is TCP as well: splitting reaches it. +func TestTCPConnOfUnwrapsProxyProtocol(t *testing.T) { + server, _ := mssTestPair(t, 0, 0) + + assert.Same(t, server, tcpConnOf(server)) + assert.Same(t, server, tcpConnOf(tcpWrapper{conn: server})) + assert.Nil(t, tcpConnOf(nonTCPConn{})) +} + +type tcpWrapper struct { + net.Conn + + conn *net.TCPConn +} + +func (w tcpWrapper) TCPConn() (*net.TCPConn, bool) { return w.conn, true } + +type nonTCPConn struct { + net.Conn +} + +// Not TCP: the data is written in one go, without errors. +func TestWriteFragmentedFallsBackForNonTCP(t *testing.T) { + left, right := net.Pipe() + defer left.Close() //nolint: errcheck + defer right.Close() //nolint: errcheck + + got := readAllAsync(right, 3) + + n, err := writeFragmented(left, []byte("abc"), 92, time.Time{}) + require.NoError(t, err) + assert.Equal(t, 3, n) + assert.Equal(t, []byte("abc"), <-got) +} + +func getsockoptMSS(conn *net.TCPConn) (int, error) { + rawConn, err := conn.SyscallConn() + if err != nil { + return 0, err //nolint: wrapcheck + } + + var ( + mss int + sysErr error + ) + + if err := rawConn.Control(func(fd uintptr) { + mss, sysErr = unix.GetsockoptInt(int(fd), unix.IPPROTO_TCP, unix.TCP_MAXSEG) + }); err != nil { + return 0, err //nolint: wrapcheck + } + + return mss, sysErr //nolint: wrapcheck +} diff --git a/mtglib/server_hello_mss_other.go b/mtglib/server_hello_mss_other.go new file mode 100644 index 000000000..c5382d150 --- /dev/null +++ b/mtglib/server_hello_mss_other.go @@ -0,0 +1,14 @@ +//go:build !linux + +package mtglib + +import ( + "net" + "time" +) + +// writeFragmented writes data in one go outside Linux: client-mss needs +// TCP_INFO and tcpi_notsent_bytes, so it works only on Linux. +func writeFragmented(conn net.Conn, data []byte, _ int, _ time.Time) (int, error) { + return conn.Write(data) //nolint: wrapcheck +} diff --git a/network/sockopts_mss_linux.go b/network/sockopts_mss_linux.go new file mode 100644 index 000000000..5d96bf638 --- /dev/null +++ b/network/sockopts_mss_linux.go @@ -0,0 +1,42 @@ +//go:build linux + +package network + +import ( + "fmt" + "syscall" + + "golang.org/x/sys/unix" +) + +// ListenerMSSSupported tells whether the platform can set the MSS of a +// listening socket (TCP_MAXSEG). Outside Linux network.client-mss is ignored. +const ListenerMSSSupported = true + +// ListenControlMSS returns a Control function for net.ListenConfig that sets +// TCP_MAXSEG on the listening socket before bind/listen. Accepted connections +// inherit the value: the SYN-ACK advertises this MSS to the client, and the +// kernel never sends the client segments larger than that until the +// connection ends (mss_clamp is fixed during the handshake and cannot be +// raised later with setsockopt). mss <= 0 leaves the socket alone. +func ListenControlMSS(mss int) func(network, address string, conn syscall.RawConn) error { + return func(_, _ string, conn syscall.RawConn) error { + if mss <= 0 { + return nil + } + + var opErr error + + if err := conn.Control(func(fd uintptr) { + opErr = unix.SetsockoptInt(int(fd), unix.IPPROTO_TCP, unix.TCP_MAXSEG, mss) + }); err != nil { + return fmt.Errorf("cannot access listener socket: %w", err) + } + + if opErr != nil { + return fmt.Errorf("cannot set TCP_MAXSEG=%d: %w", mss, opErr) + } + + return nil + } +} diff --git a/network/sockopts_mss_linux_test.go b/network/sockopts_mss_linux_test.go new file mode 100644 index 000000000..cba0a0933 --- /dev/null +++ b/network/sockopts_mss_linux_test.go @@ -0,0 +1,124 @@ +//go:build linux + +package network_test + +import ( + "io" + "net" + "testing" + + "github.com/9seconds/mtg/v2/network" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/sys/unix" +) + +func tcpMaxSeg(t *testing.T, conn net.Conn) int { + t.Helper() + + rawConn, err := conn.(*net.TCPConn).SyscallConn() //nolint: forcetypeassert + require.NoError(t, err) + + var ( + mss int + sysErr error + ) + + require.NoError(t, rawConn.Control(func(fd uintptr) { + mss, sysErr = unix.GetsockoptInt(int(fd), unix.IPPROTO_TCP, unix.TCP_MAXSEG) + })) + require.NoError(t, sysErr) + + return mss +} + +func dialPair(t *testing.T, listenerMSS int) (net.Conn, net.Conn) { + t.Helper() + + lc := net.ListenConfig{Control: network.ListenControlMSS(listenerMSS)} + + ln, err := lc.Listen(t.Context(), "tcp4", "127.0.0.1:0") + require.NoError(t, err) + + defer ln.Close() //nolint: errcheck + + client, err := net.Dial("tcp4", ln.Addr().String()) + require.NoError(t, err) + + t.Cleanup(func() { client.Close() }) //nolint: errcheck + + server, err := ln.Accept() + require.NoError(t, err) + + t.Cleanup(func() { server.Close() }) //nolint: errcheck + + return server, client +} + +// TCP_MAXSEG of the listening socket is advertised to the client in the +// SYN-ACK: both sides then send segments no larger than that +// (client-mss-bulk = 0). +func TestListenControlMSSAdvertisedInSynAck(t *testing.T) { + server, client := dialPair(t, 92) + + clientMSS := tcpMaxSeg(t, client) + serverMSS := tcpMaxSeg(t, server) + + assert.LessOrEqual(t, clientMSS, 92) + assert.LessOrEqual(t, serverMSS, 92) + t.Logf("listener MSS 92: client TCP_MAXSEG=%d, server TCP_MAXSEG=%d", clientMSS, serverMSS) +} + +func TestListenControlMSSZeroKeepsDefault(t *testing.T) { + server, client := dialPair(t, 0) + + assert.Greater(t, tcpMaxSeg(t, client), 1000) + assert.Greater(t, tcpMaxSeg(t, server), 1000) +} + +func TestListenControlMSSRejectsTooSmall(t *testing.T) { + lc := net.ListenConfig{Control: network.ListenControlMSS(40)} + + _, err := lc.Listen(t.Context(), "tcp4", "127.0.0.1:0") + assert.Error(t, err) +} + +// This is why client-mss-bulk goes on the listener: setsockopt(TCP_MAXSEG) on +// an established connection does not raise the segment size, mss_clamp is +// fixed during the TCP handshake. +func TestTCPMaxSegCannotBeRaisedAfterAccept(t *testing.T) { + server, client := dialPair(t, 92) + + rawConn, err := server.(*net.TCPConn).SyscallConn() //nolint: forcetypeassert + require.NoError(t, err) + + var sysErr error + + require.NoError(t, rawConn.Control(func(fd uintptr) { + sysErr = unix.SetsockoptInt(int(fd), unix.IPPROTO_TCP, unix.TCP_MAXSEG, 1400) + })) + require.NoError(t, sysErr) + + done := make(chan struct{}) + + go func() { + defer close(done) + + buf := make([]byte, 64*1024) + io.ReadFull(client, buf) //nolint: errcheck + }() + + _, err = server.Write(make([]byte, 64*1024)) + require.NoError(t, err) + <-done + + var info *unix.TCPInfo + + require.NoError(t, rawConn.Control(func(fd uintptr) { + info, sysErr = unix.GetsockoptTCPInfo(int(fd), unix.IPPROTO_TCP, unix.TCP_INFO) + })) + require.NoError(t, sysErr) + + assert.LessOrEqual(t, int(info.Snd_mss), 92) + t.Logf("listener MSS 92, then setsockopt 1400: tcpi_snd_mss=%d", info.Snd_mss) +} diff --git a/network/sockopts_mss_other.go b/network/sockopts_mss_other.go new file mode 100644 index 000000000..40f0f10e9 --- /dev/null +++ b/network/sockopts_mss_other.go @@ -0,0 +1,17 @@ +//go:build !linux + +package network + +import "syscall" + +// ListenerMSSSupported tells whether the platform can set the MSS of a +// listening socket (TCP_MAXSEG). Outside Linux network.client-mss is ignored. +const ListenerMSSSupported = false + +// ListenControlMSS does nothing outside Linux: client-mss works only on +// Linux. +func ListenControlMSS(_ int) func(network, address string, conn syscall.RawConn) error { + return func(_, _ string, _ syscall.RawConn) error { + return nil + } +}