Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions example.config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
42 changes: 42 additions & 0 deletions internal/cli/client_mss.go
Original file line number Diff line number Diff line change
@@ -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
}
49 changes: 49 additions & 0 deletions internal/cli/client_mss_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
5 changes: 4 additions & 1 deletion internal/cli/run_proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -326,14 +328,15 @@ 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)
if err != nil {
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)
}
Expand Down
85 changes: 85 additions & 0 deletions internal/config/client_mss_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
79 changes: 75 additions & 4 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions internal/config/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
44 changes: 44 additions & 0 deletions internal/config/type_tcp_mss.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading