diff --git a/example.config.toml b/example.config.toml index abc0bff..ece3104 100644 --- a/example.config.toml +++ b/example.config.toml @@ -204,14 +204,22 @@ proxies = [ # define a global timeout on establishing of network connections. idle # means a timeout on pumping data between sockset when nothing is # happening. -# -# please be noticed that handshakes have no timeouts intentionally. You can -# find a reasoning here: -# https://www.ndss-symposium.org/wp-content/uploads/2020/02/23087-paper.pdf [network.timeout] tcp = "5s" http = "10s" -idle = "1m" +idle = "5m" +handshake = "10s" + +# this defines a configuration for TCP keep alives. Default values are taken +# from Golang default behavior. +[network.keep-alive] +disabled = false +# idle means a time period after which we start sending TCP Keep Alive probes +idle = "15s" +# interval is a period between 2 consecutive probes +interval = "15s" +# if we miss that many probes, a connection will be considered as a dead one. +count = 9 # mtg has to mimic real websites. It does not mean domain fronting, it also # means that traffic characteristics should be similar to real world traffic. diff --git a/go.mod b/go.mod index 5334e45..6067d7a 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.26 require ( github.com/OneOfOne/xxhash v1.2.8 - github.com/alecthomas/kong v1.14.0 + github.com/alecthomas/kong v1.15.0 github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 github.com/babolivier/go-doh-client v0.0.0-20201028162107-a76cff4cb8b6 @@ -28,7 +28,7 @@ require ( require ( github.com/beevik/ntp v1.5.0 - github.com/ncruces/go-dns v1.3.2 + github.com/ncruces/go-dns v1.3.3 github.com/pelletier/go-toml/v2 v2.3.0 github.com/pires/go-proxyproto v0.11.0 github.com/things-go/go-socks5 v0.1.0 diff --git a/go.sum b/go.sum index 8161acc..1975b1d 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ github.com/OneOfOne/xxhash v1.2.8 h1:31czK/TI9sNkxIKfaUfGlU47BAxQ0ztGgd9vPyqimf8 github.com/OneOfOne/xxhash v1.2.8/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= -github.com/alecthomas/kong v1.14.0 h1:gFgEUZWu2ZmZ+UhyZ1bDhuutbKN1nTtJTwh19Wsn21s= -github.com/alecthomas/kong v1.14.0/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I= +github.com/alecthomas/kong v1.15.0 h1:BVJstKbpO73zKpmIu+m/aLRrNmWwxXPIGTNin9VmLVI= +github.com/alecthomas/kong v1.15.0/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vSQ6PWWSL9lK8qwHozUj03+zLoEB8O0= @@ -48,8 +48,8 @@ github.com/miekg/dns v1.1.51 h1:0+Xg7vObnhrz/4ZCZcZh7zPXlmU0aveS2HDBd0m0qSo= github.com/miekg/dns v1.1.51/go.mod h1:2Z9d3CP1LQWihRZUf29mQ19yDThaI4DAYzte2CaQW5c= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/ncruces/go-dns v1.3.2 h1:kBLuUZBgkQ4qF4WDXZRQ4rG0Gk6sLVJQ5tESkWrxUa0= -github.com/ncruces/go-dns v1.3.2/go.mod h1:tuzixNY8PY/M7yUzcvRbUaeLs3ifIdydpi5H2bfRU+s= +github.com/ncruces/go-dns v1.3.3 h1:59OV7XoJrTCoUMZjWRVs4GOjtntMTZqiQ5Mn+BT13hk= +github.com/ncruces/go-dns v1.3.3/go.mod h1:tuzixNY8PY/M7yUzcvRbUaeLs3ifIdydpi5H2bfRU+s= github.com/panjf2000/ants/v2 v2.12.0 h1:u9JhESo83i/GkZnhfTNuFMMWcNt7mnV1bGJ6FT4wXH8= github.com/panjf2000/ants/v2 v2.12.0/go.mod h1:tSQuaNQ6r6NRhPt+IZVUevvDyFMTs+eS4ztZc52uJTY= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index b00c3e0..48563bc 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -97,6 +97,12 @@ func (d *Doctor) Run(cli *CLI, version string) error { conf.Network.Timeout.TCP.Get(10*time.Second), conf.Network.Timeout.HTTP.Get(0), conf.Network.Timeout.Idle.Get(0), + net.KeepAliveConfig{ + Enable: !conf.Network.KeepAlive.Disabled.Get(false), + Idle: conf.Network.KeepAlive.Idle.Get(0), + Interval: conf.Network.KeepAlive.Interval.Get(0), + Count: int(conf.Network.KeepAlive.Count.Get(0)), + }, ) fmt.Println("Validate native network connectivity") diff --git a/internal/cli/run_proxy.go b/internal/cli/run_proxy.go index c57374b..5f0b3de 100644 --- a/internal/cli/run_proxy.go +++ b/internal/cli/run_proxy.go @@ -5,7 +5,6 @@ import ( "fmt" "net" "os" - "time" "github.com/9seconds/mtg/v2/antireplay" "github.com/9seconds/mtg/v2/events" @@ -51,6 +50,12 @@ func makeNetwork(conf *config.Config, version string) (mtglib.Network, error) { conf.Network.Timeout.TCP.Get(0), conf.Network.Timeout.HTTP.Get(0), conf.Network.Timeout.Idle.Get(0), + net.KeepAliveConfig{ + Enable: !conf.Network.KeepAlive.Disabled.Get(false), + Idle: conf.Network.KeepAlive.Idle.Get(0), + Interval: conf.Network.KeepAlive.Interval.Get(0), + Count: int(conf.Network.KeepAlive.Count.Get(0)), + }, ) proxyDialers := make([]mtglib.Network, len(conf.Network.Proxies)) @@ -263,7 +268,8 @@ func runProxy(conf *config.Config, version string) error { //nolint: funlen AllowFallbackOnUnknownDC: conf.AllowFallbackOnUnknownDC.Get(false), TolerateTimeSkewness: conf.TolerateTimeSkewness.Value, - IdleTimeout: conf.Network.Timeout.Idle.Get(time.Minute), + IdleTimeout: conf.Network.Timeout.Idle.Get(mtglib.DefaultIdleTimeout), + HandshakeTimeout: conf.Network.Timeout.Handshake.Get(mtglib.DefaultHandshakeTimeout), DoppelGangerURLs: doppelGangerURLs, DoppelGangerPerRaid: conf.Defense.Doppelganger.Repeats.Get(mtglib.DoppelGangerPerRaid), diff --git a/internal/config/config.go b/internal/config/config.go index dcc3186..70e233f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -60,10 +60,17 @@ type Config struct { } `json:"defense"` Network struct { Timeout struct { - TCP TypeDuration `json:"tcp"` - HTTP TypeDuration `json:"http"` - Idle TypeDuration `json:"idle"` + TCP TypeDuration `json:"tcp"` + HTTP TypeDuration `json:"http"` + Idle TypeDuration `json:"idle"` + Handshake TypeDuration `json:"handshake"` } `json:"timeout"` + KeepAlive struct { + Disabled TypeBool `json:"disabled"` + Idle TypeDuration `json:"idle"` + Interval TypeDuration `json:"interval"` + Count TypeConcurrency `json:"count"` + } `json:"keepAlive"` DOHIP TypeIP `json:"dohIp"` DNS TypeDNSURI `json:"dns"` Proxies []TypeProxyURL `json:"proxies"` diff --git a/internal/config/parse.go b/internal/config/parse.go index 6bd16f7..bdc7616 100644 --- a/internal/config/parse.go +++ b/internal/config/parse.go @@ -55,10 +55,17 @@ type tomlConfig struct { } `toml:"defense" json:"defense,omitempty"` Network struct { Timeout struct { - TCP string `toml:"tcp" json:"tcp,omitempty"` - HTTP string `toml:"http" json:"http,omitempty"` - Idle string `toml:"idle" json:"idle,omitempty"` + TCP string `toml:"tcp" json:"tcp,omitempty"` + HTTP string `toml:"http" json:"http,omitempty"` + Idle string `toml:"idle" json:"idle,omitempty"` + Handshake string `toml:"handshake" json:"handshake,omitempty"` } `toml:"timeout" json:"timeout,omitempty"` + KeepAlive struct { + Disabled bool `toml:"disabled" json:"disabled,omitempty"` + Idle string `toml:"idle" json:"idle,omitempty"` + Interval string `toml:"interval" json:"interval,omitempty"` + Count uint `toml:"count" json:"count,omitempty"` + } `toml:"keep-alive" json:"keepAlive,omitempty"` DOHIP string `toml:"doh-ip" json:"dohIp,omitempty"` DNS string `toml:"dns" json:"dns,omitempty"` Proxies []string `toml:"proxies" json:"proxies,omitempty"` diff --git a/mtglib/init.go b/mtglib/init.go index 3d7f630..dccd5ac 100644 --- a/mtglib/init.go +++ b/mtglib/init.go @@ -77,8 +77,13 @@ const ( // DefaultIdleTimeout is a default timeout for closing a connection in case of // idling. // - // Deprecated: no longer in use because of changed TCP relay algorithm. - DefaultIdleTimeout = time.Minute + // Set to 5 minutes to survive typical mobile sleep periods (2-5 min) and + // avoid racing with MTProto ping_delay_disconnect (~60s interval). + DefaultIdleTimeout = 5 * time.Minute + + // DefaultHandshakeTimeout defines a time period during which the + // all handshake ceremonies must be completed. + DefaultHandshakeTimeout = 10 * time.Second // DefaultTolerateTimeSkewness is a default timeout for time skewness on a // faketls timeout verification. diff --git a/mtglib/internal/tls/fake/client_side.go b/mtglib/internal/tls/fake/client_side.go index 66542bc..e13c02b 100644 --- a/mtglib/internal/tls/fake/client_side.go +++ b/mtglib/internal/tls/fake/client_side.go @@ -6,6 +6,7 @@ import ( "crypto/sha256" "crypto/subtle" "encoding/binary" + "errors" "fmt" "io" "net" @@ -20,12 +21,19 @@ const ( // record_type(1) + version(2) + size(2) + handshake_type(1) + uint24_length(3) + client_version(2) RandomOffset = 1 + 2 + 2 + 1 + 3 + 2 + // https://datatracker.ietf.org/doc/html/rfc8701#name-grease-values + // https://medium.com/asecuritysite-when-bob-met-alice/in-cybersecurity-what-is-grease-9f8850558dea + GreaseMask = 0x0f0f + GreaseValueType = 0x0a0a + sniDNSNamesListType = 0 ) var ( emptyRandom = [RandomLen]byte{} extTypeSNI = [2]byte{} + + ErrCannotFindCipher = errors.New("cannot find a cipher") ) type ClientHello struct { @@ -40,11 +48,6 @@ func ReadClientHello( hostname string, tolerateTimeSkewness time.Duration, ) (*ClientHello, error) { - if err := conn.SetReadDeadline(time.Now().Add(ClientHelloReadTimeout)); err != nil { - return nil, fmt.Errorf("cannot set read deadline: %w", err) - } - defer conn.SetReadDeadline(resetDeadline) //nolint: errcheck - // This is how FakeTLS is organized: // 1. We create sha256 HMAC with a given secret // 2. We dump there a whole TLS frame except of the fact that random @@ -130,16 +133,27 @@ func parseHandshake(r io.Reader) (*ClientHello, error) { cipherSuiteLen := int64(binary.BigEndian.Uint16(header[:])) - // we do not care about picking up any cipher. we pick the first one, - // so it is always should be present. - if _, err := io.ReadFull(r, header[:]); err != nil { - return nil, fmt.Errorf("cannot read first cipher suite: %w", err) + // Pick the first non-GREASE cipher suite from the list. + // Real TLS servers never select GREASE values (RFC 8701, pattern 0x?a?a), + // so echoing them back is a trivial DPI fingerprint. + // cipherSuiteLen is in bytes; each cipher suite is 2 bytes. + for range cipherSuiteLen / 2 { + if _, err := io.ReadFull(r, header[:]); err != nil { + return nil, fmt.Errorf("cannot read cipher suite: %w", err) + } + + if hello.CipherSuite != 0 { + // do not forget we have to scan until the end + continue + } + + if cs := binary.BigEndian.Uint16(header[:]); cs&GreaseMask != GreaseValueType { + hello.CipherSuite = cs + } } - hello.CipherSuite = binary.BigEndian.Uint16(header[:]) - - if _, err := io.CopyN(io.Discard, r, cipherSuiteLen-2); err != nil { - return nil, fmt.Errorf("cannot skip remaining cipher suites: %w", err) + if hello.CipherSuite == 0 { + return nil, ErrCannotFindCipher } if _, err := io.ReadFull(r, header[:1]); err != nil { diff --git a/mtglib/internal/tls/fake/client_side_snapshot_test.go b/mtglib/internal/tls/fake/client_side_snapshot_test.go index e4e0480..ab44ea0 100644 --- a/mtglib/internal/tls/fake/client_side_snapshot_test.go +++ b/mtglib/internal/tls/fake/client_side_snapshot_test.go @@ -12,7 +12,6 @@ import ( "github.com/9seconds/mtg/v2/mtglib" "github.com/9seconds/mtg/v2/mtglib/internal/tls/fake" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" ) @@ -71,11 +70,6 @@ func (suite *ParseClientHelloSnapshotTestSuite) makeConn(data []byte) *parseClie readBuf: readBuf, } - connMock. - On("SetReadDeadline", mock.AnythingOfType("time.Time")). - Twice(). - Return(nil) - return connMock } diff --git a/mtglib/internal/tls/fake/client_side_test.go b/mtglib/internal/tls/fake/client_side_test.go index bf23409..5c717e0 100644 --- a/mtglib/internal/tls/fake/client_side_test.go +++ b/mtglib/internal/tls/fake/client_side_test.go @@ -2,9 +2,9 @@ package fake_test import ( "bytes" + cryptotls "crypto/tls" "encoding/binary" "encoding/json" - "errors" "io" "os" "testing" @@ -14,7 +14,6 @@ import ( "github.com/9seconds/mtg/v2/mtglib" "github.com/9seconds/mtg/v2/mtglib/internal/tls" "github.com/9seconds/mtg/v2/mtglib/internal/tls/fake" - "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" ) @@ -53,11 +52,6 @@ func (suite *ParseClientHelloTestSuite) SetupTest() { suite.connMock = &parseClientHelloConnMock{ readBuf: suite.readBuf, } - - suite.connMock. - On("SetReadDeadline", mock.AnythingOfType("time.Time")). - Twice(). - Return(nil) } func (suite *ParseClientHelloTestSuite) TearDownTest() { @@ -69,23 +63,11 @@ type ParseClientHello_TLSHeaderTestSuite struct { } func (suite *ParseClientHello_TLSHeaderTestSuite) TestEmpty() { - suite.connMock.ExpectedCalls = []*mock.Call{} - suite.connMock. - On("SetReadDeadline", mock.AnythingOfType("time.Time")). - Once(). - Return(errors.New("fail")) - _, err := fake.ReadClientHello(suite.connMock, suite.secret.Key[:], suite.secret.Host, TolerateTime) - suite.ErrorContains(err, "fail") + suite.ErrorContains(err, "cannot read client hello") } func (suite *ParseClientHello_TLSHeaderTestSuite) TestNothing() { - suite.connMock.ExpectedCalls = []*mock.Call{} - suite.connMock. - On("SetReadDeadline", mock.AnythingOfType("time.Time")). - Twice(). - Return(nil) - _, err := fake.ReadClientHello(suite.connMock, suite.secret.Key[:], suite.secret.Host, TolerateTime) suite.ErrorIs(err, io.EOF) } @@ -234,12 +216,13 @@ func (suite *ParseClientHelloHandshakeBodyTestSuite) TestCannotReadCipherSuiteLe } func (suite *ParseClientHelloHandshakeBodyTestSuite) TestCannotReadFirstCipherSuite() { - body := make([]byte, 2+fake.RandomLen+1+2) + body := make([]byte, 2+fake.RandomLen+1+2+1) // cipherSuiteLen=2 but only 1 byte available + binary.BigEndian.PutUint16(body[2+fake.RandomLen+1:], 2) suite.writeBody(body) _, err := fake.ReadClientHello(suite.connMock, suite.secret.Key[:], suite.secret.Host, TolerateTime) - suite.ErrorContains(err, "cannot read first cipher suite") + suite.ErrorContains(err, "cannot read cipher suite") } func (suite *ParseClientHelloHandshakeBodyTestSuite) TestCannotSkipRemainingCipherSuites() { @@ -249,12 +232,27 @@ func (suite *ParseClientHelloHandshakeBodyTestSuite) TestCannotSkipRemainingCiph suite.writeBody(body) _, err := fake.ReadClientHello(suite.connMock, suite.secret.Key[:], suite.secret.Host, TolerateTime) - suite.ErrorContains(err, "cannot skip remaining cipher suites") + suite.ErrorContains(err, "cannot read cipher suite") +} + +func (suite *ParseClientHelloHandshakeBodyTestSuite) TestCannotFindCipher() { + // All cipher suites are GREASE values — must return ErrCannotFindCipher. + body := make([]byte, 2+fake.RandomLen+1+2+4+1) + binary.BigEndian.PutUint16(body[2+fake.RandomLen+1:], 4) + binary.BigEndian.PutUint16(body[2+fake.RandomLen+1+2:], 0x0a0a) + binary.BigEndian.PutUint16(body[2+fake.RandomLen+1+2+2:], 0x1a1a) + body[2+fake.RandomLen+1+2+4] = 1 + + suite.writeBody(body) + + _, err := fake.ReadClientHello(suite.connMock, suite.secret.Key[:], suite.secret.Host, TolerateTime) + suite.ErrorIs(err, fake.ErrCannotFindCipher) } func (suite *ParseClientHelloHandshakeBodyTestSuite) TestCannotReadCompressionMethodsLength() { body := make([]byte, 2+fake.RandomLen+1+2+2) binary.BigEndian.PutUint16(body[2+fake.RandomLen+1:], 2) + binary.BigEndian.PutUint16(body[2+fake.RandomLen+1+2:], cryptotls.TLS_AES_128_GCM_SHA256) suite.writeBody(body) @@ -265,6 +263,7 @@ func (suite *ParseClientHelloHandshakeBodyTestSuite) TestCannotReadCompressionMe func (suite *ParseClientHelloHandshakeBodyTestSuite) TestCannotSkipCompressionMethods() { body := make([]byte, 2+fake.RandomLen+1+2+2+1) binary.BigEndian.PutUint16(body[2+fake.RandomLen+1:], 2) + binary.BigEndian.PutUint16(body[2+fake.RandomLen+1+2:], cryptotls.TLS_AES_128_GCM_SHA256) body[2+fake.RandomLen+1+2+2] = 1 suite.writeBody(body) @@ -300,6 +299,7 @@ func (suite *ParseClientHelloSNITestSuite) writeExtensions(extensions []byte) { // cipherSuite(2) + compressionLen(1) + compression(1) = 41 body := make([]byte, 41) binary.BigEndian.PutUint16(body[35:], 2) + binary.BigEndian.PutUint16(body[37:], cryptotls.TLS_AES_128_GCM_SHA256) body[39] = 1 suite.readBuf.Write(body) @@ -478,11 +478,6 @@ func (s *ParseClientHelloFragmentedTestSuite) makeConn(data []byte) *parseClient readBuf: readBuf, } - connMock. - On("SetReadDeadline", mock.AnythingOfType("time.Time")). - Twice(). - Return(nil) - return connMock } diff --git a/mtglib/internal/tls/fake/init.go b/mtglib/internal/tls/fake/init.go index 77bbe6b..0e60876 100644 --- a/mtglib/internal/tls/fake/init.go +++ b/mtglib/internal/tls/fake/init.go @@ -2,15 +2,6 @@ package fake import ( "errors" - "time" ) -const ( - ClientHelloReadTimeout = 5 * time.Second -) - -var ( - resetDeadline time.Time - - ErrBadDigest = errors.New("incorrect client random") -) +var ErrBadDigest = errors.New("incorrect client random") diff --git a/mtglib/internal/tls/fake/server_side_test.go b/mtglib/internal/tls/fake/server_side_test.go index 1a9b5f2..6362a52 100644 --- a/mtglib/internal/tls/fake/server_side_test.go +++ b/mtglib/internal/tls/fake/server_side_test.go @@ -58,7 +58,7 @@ func (suite *SendServerHelloTestSuite) TestRecordStructure() { recordType, length, err := tls.ReadRecord(suite.buf, &rec) suite.NoError(err) suite.Equal(byte(tls.TypeApplicationData), recordType) - suite.Greater(length, int64(2500)) + suite.GreaterOrEqual(length, int64(2500)) suite.Empty(suite.buf.Bytes()) } diff --git a/mtglib/internal/tls/fake/testdata/client-hello-ok-grease-first.json b/mtglib/internal/tls/fake/testdata/client-hello-ok-grease-first.json new file mode 100644 index 0000000..0aedf10 --- /dev/null +++ b/mtglib/internal/tls/fake/testdata/client-hello-ok-grease-first.json @@ -0,0 +1,8 @@ +{ + "time": 1617181365, + "random": "w4TaDfYg/aUKdx1oi68vxMKvHJczRNvtRRppLETzeNE=", + "sessionId": "St2BZ2uHMFn3B2trD1jfdtpjoJOOg6JBeLhFcyCMCq4=", + "host": "storage.googleapis.com", + "cipherSuite": 4867, + "full": "FgMBAgIBAAH+AwPDhNoN9iD9pQp3HWiLry/Ewq8clzNE2+1FGmksRPN40SBK3YFna4cwWfcHa2sPWN922mOgk46DokF4uEVzIIwKrgA2WloTAxMBEwLALMArwCTAI8AKwAnMqcAwwC/AKMAnwBTAE8yoAJ0AnAA9ADwANQAvwAjAEgAKAQABf/8BAAEAAAAAGwAZAAAWc3RvcmFnZS5nb29nbGVhcGlzLmNvbQAXAAAADQAYABYEAwgEBAEFAwIDCAUIBQUBCAYGAQIBAAUABQEAAAAAM3QAAAASAAAAEAAwAC4CaDIFaDItMTYFaDItMTUFaDItMTQIc3BkeS8zLjEGc3BkeS8zCGh0dHAvMS4xAAsAAgEAADMAJgAkAB0AIAf+6C8fSRJSAC7CyUvdR9kDclNR9KLCsCFHpVZ3bC8iAC0AAgEBACsACQgDBAMDAwIDAQAKAAoACAAdABcAGAAZABUAoQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +} \ No newline at end of file diff --git a/mtglib/proxy.go b/mtglib/proxy.go index 7acf8b7..a89e18c 100644 --- a/mtglib/proxy.go +++ b/mtglib/proxy.go @@ -28,6 +28,7 @@ type Proxy struct { allowFallbackOnUnknownDC bool tolerateTimeSkewness time.Duration idleTimeout time.Duration + handshakeTimeout time.Duration domainFrontingPort int domainFrontingIP string domainFrontingProxyProtocol bool @@ -66,6 +67,11 @@ func (p *Proxy) ServeConn(conn essentials.Conn) { ctx := newStreamContext(p.ctx, p.logger, conn) defer ctx.Close() + if err := ctx.clientConn.SetDeadline(time.Now().Add(p.handshakeTimeout)); err != nil { + ctx.logger.WarningError("cannot set handshake timeout", err) + return + } + stop := context.AfterFunc(ctx, func() { ctx.Close() }) @@ -97,6 +103,11 @@ func (p *Proxy) ServeConn(conn essentials.Conn) { return } + if err := ctx.clientConn.SetDeadline(time.Time{}); err != nil { + ctx.logger.WarningError("cannot set deadline", err) + return + } + if err := p.doTelegramCall(ctx); err != nil { ctx.logger.WarningError("cannot dial to telegram", err) return @@ -346,6 +357,7 @@ func NewProxy(opts ProxyOpts) (*Proxy, error) { domainFrontingIP: opts.DomainFrontingIP, tolerateTimeSkewness: opts.getTolerateTimeSkewness(), idleTimeout: opts.getIdleTimeout(), + handshakeTimeout: opts.getHandshakeTimeout(), allowFallbackOnUnknownDC: opts.AllowFallbackOnUnknownDC, telegram: tg, doppelGanger: doppel.NewGanger( diff --git a/mtglib/proxy_opts.go b/mtglib/proxy_opts.go index fd44783..8dc69a5 100644 --- a/mtglib/proxy_opts.go +++ b/mtglib/proxy_opts.go @@ -70,6 +70,12 @@ type ProxyOpts struct { // This is an optional setting. IdleTimeout time.Duration + // HandshakeTimeout is a timeout during which all handshake ceremonies must + // be completed, otherwise this process will be aborted + // + // This is an optional setting. + HandshakeTimeout time.Duration + // TolerateTimeSkewness is a time boundary that defines a time range where // faketls timestamp is acceptable. // @@ -215,9 +221,17 @@ func (p ProxyOpts) getPreferIP() string { return p.PreferIP } +func (p ProxyOpts) getHandshakeTimeout() time.Duration { + if p.HandshakeTimeout == 0 { + return DefaultHandshakeTimeout + } + + return p.HandshakeTimeout +} + func (p ProxyOpts) getIdleTimeout() time.Duration { if p.IdleTimeout == 0 { - return time.Minute + return DefaultIdleTimeout } return p.IdleTimeout diff --git a/network/init.go b/network/init.go index 3baa8b0..ffebc3d 100644 --- a/network/init.go +++ b/network/init.go @@ -36,8 +36,22 @@ const ( // DefaultTCPKeepAlivePeriod defines a time period between 2 consequitive // probes. + // + // Deprecated: use DefaultKeepAliveIdle and DefaultKeepAliveInterval instead. DefaultTCPKeepAlivePeriod = 10 * time.Second + // DefaultKeepAliveIdle is the time a connection must be idle before + // the first keepalive probe is sent. + DefaultKeepAliveIdle = 30 * time.Second + + // DefaultKeepAliveInterval is the time between consecutive keepalive + // probes. + DefaultKeepAliveInterval = 10 * time.Second + + // DefaultKeepAliveCount is the number of unacknowledged probes before + // the connection is considered dead. + DefaultKeepAliveCount = 3 + // ProxyDialerOpenThreshold is used for load balancing SOCKS5 dialer only. // // This dialer uses circuit breaker with of 3 stages: OPEN, HALF_OPEN and diff --git a/network/sockopts.go b/network/sockopts.go index 22ec16b..7d537e4 100644 --- a/network/sockopts.go +++ b/network/sockopts.go @@ -20,8 +20,13 @@ func SetServerSocketOptions(conn net.Conn, bufferSize int) error { } func setCommonSocketOptions(conn *net.TCPConn) error { - if err := conn.SetKeepAlivePeriod(DefaultTCPKeepAlivePeriod); err != nil { - return fmt.Errorf("cannot set time period of TCP keepalive probes: %w", err) + if err := conn.SetKeepAliveConfig(net.KeepAliveConfig{ + Enable: true, + Idle: DefaultKeepAliveIdle, + Interval: DefaultKeepAliveInterval, + Count: DefaultKeepAliveCount, + }); err != nil { + return fmt.Errorf("cannot configure TCP keepalive: %w", err) } if err := conn.SetLinger(tcpLingerTimeout); err != nil { diff --git a/network/sockopts_test.go b/network/sockopts_test.go new file mode 100644 index 0000000..25ddbdb --- /dev/null +++ b/network/sockopts_test.go @@ -0,0 +1,93 @@ +//go:build linux || darwin +// +build linux darwin + +package network_test + +import ( + "net" + "runtime" + "syscall" + "testing" + "time" + + "github.com/9seconds/mtg/v2/network" + "github.com/stretchr/testify/require" + "golang.org/x/sys/unix" +) + +func tcpKeepIdleOption() int { + if runtime.GOOS == "darwin" { + return 0x10 // TCP_KEEPALIVE on macOS + } + + return 0x4 // TCP_KEEPIDLE on Linux +} + +func TestSetClientSocketOptionsKeepAlive(t *testing.T) { + t.Parallel() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer func() { + err := listener.Close() + require.NoError(t, err) + }() + + type dialResult struct { + conn net.Conn + err error + } + + dialDone := make(chan dialResult, 1) + + go func() { + c, err := net.Dial("tcp", listener.Addr().String()) + dialDone <- dialResult{conn: c, err: err} + }() + + tcpListener, ok := listener.(*net.TCPListener) + require.True(t, ok, "listener must be a *net.TCPListener") + + require.NoError(t, tcpListener.SetDeadline(time.Now().Add(5*time.Second))) + + accepted, err := listener.Accept() + require.NoError(t, err) + defer func() { + err := accepted.Close() + require.NoError(t, err) + }() + + dr := <-dialDone + require.NoError(t, dr.err) + defer func() { + err := dr.conn.Close() + require.NoError(t, err) + }() + + err = network.SetClientSocketOptions(accepted, 0) + require.NoError(t, err) + + tcpConn := accepted.(*net.TCPConn) + + rawConn, err := tcpConn.SyscallConn() + require.NoError(t, err) + + err = rawConn.Control(func(fd uintptr) { + val, err := unix.GetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_KEEPALIVE) + require.NoError(t, err) + require.NotEqual(t, 0, val, "SO_KEEPALIVE should be enabled") + + idle, err := unix.GetsockoptInt(int(fd), syscall.IPPROTO_TCP, tcpKeepIdleOption()) + require.NoError(t, err) + require.Equal(t, int(network.DefaultKeepAliveIdle.Seconds()), idle) + + interval, err := unix.GetsockoptInt(int(fd), syscall.IPPROTO_TCP, unix.TCP_KEEPINTVL) + require.NoError(t, err) + require.Equal(t, int(network.DefaultKeepAliveInterval.Seconds()), interval) + + count, err := unix.GetsockoptInt(int(fd), syscall.IPPROTO_TCP, unix.TCP_KEEPCNT) + require.NoError(t, err) + require.Equal(t, network.DefaultKeepAliveCount, count) + }) + require.NoError(t, err) +} diff --git a/network/v2/base_http_test.go b/network/v2/base_http_test.go index 9047782..926db42 100644 --- a/network/v2/base_http_test.go +++ b/network/v2/base_http_test.go @@ -25,7 +25,7 @@ func (suite *BaseHTTPTestSuite) SetupSuite() { } func (suite *BaseHTTPTestSuite) SetupTest() { - suite.client = network.New(nil, "mtg/1", 0, 0, 0).MakeHTTPClient(nil) + suite.client = network.New(nil, "mtg/1", 0, 0, 0, network.DefaultKeepAliveConfig).MakeHTTPClient(nil) } func (suite *BaseHTTPTestSuite) TestGet() { diff --git a/network/v2/base_network_test.go b/network/v2/base_network_test.go index f2d7a10..32fbe35 100644 --- a/network/v2/base_network_test.go +++ b/network/v2/base_network_test.go @@ -19,7 +19,7 @@ type BaseNetworkTestSuite struct { func (suite *BaseNetworkTestSuite) SetupSuite() { suite.EchoServerTestSuite.SetupSuite() - suite.net = network.New(nil, "agent", 0, 0, 0) + suite.net = network.New(nil, "agent", 0, 0, 0, network.DefaultKeepAliveConfig) } func (suite *BaseNetworkTestSuite) TestDialUnknownNetwork() { diff --git a/network/v2/init.go b/network/v2/init.go index 14c6144..3cbcd27 100644 --- a/network/v2/init.go +++ b/network/v2/init.go @@ -11,6 +11,7 @@ package network import ( "errors" + "net" "time" ) @@ -26,14 +27,55 @@ const ( // DefaultTCPKeepAlivePeriod defines a time period between 2 consecuitive // probes. + // + // Deprecated: use DefaultKeepAliveConfig DefaultTCPKeepAlivePeriod = 10 * time.Second + // DefaultKeepAliveIdle is the time a connection must be idle before + // the first keepalive probe is sent. + // + // Deprecated: use DefaultKeepAliveConfig + DefaultKeepAliveIdle = 30 * time.Second + + // DefaultKeepAliveInterval is the time between consecutive keepalive + // probes. + // + // Deprecated: use DefaultKeepAliveConfig + DefaultKeepAliveInterval = 10 * time.Second + + // DefaultKeepAliveCount is the number of unacknowledged probes before + // the connection is considered dead. + // + // Deprecated: use DefaultKeepAliveConfig + DefaultKeepAliveCount = 3 + // User Agent to use in HTTP client. UserAgent = "curl/8.5.0" // tcpLingerTimeout defines a number of seconds to wait for sending // unacknowledged data. tcpLingerTimeout = 1 + + // tcpNotSentLowat limits the amount of unsent data queued in the + // kernel write buffer per socket. When the unsent data drops below + // this threshold, the socket becomes writable again. This reduces + // per-connection memory usage and bufferbloat by applying + // back-pressure to the relay loop instead of piling up data in + // kernel buffers. + tcpNotSentLowat = 128 * 1024 ) -var ErrCannotDial = errors.New("cannot dial to any address") +var ( + ErrCannotDial = errors.New("cannot dial to any address") + + // DefaultKeepAliveConfig defines a default configuration for + // keep alive settings. As per official documentation, if keep alive + // is enabled, then: + // + // Idle = 15 * time.Second + // Interval = 15 * time.Second + // Count = 9 + DefaultKeepAliveConfig = net.KeepAliveConfig{ + Enable: true, + } +) diff --git a/network/v2/network.go b/network/v2/network.go index 3f961ed..0590b14 100644 --- a/network/v2/network.go +++ b/network/v2/network.go @@ -14,9 +14,10 @@ import ( type network struct { net.Dialer - httpTimeout time.Duration - idleTimeout time.Duration - userAgent string + keepAliveConfig net.KeepAliveConfig + httpTimeout time.Duration + idleTimeout time.Duration + userAgent string } func (n *network) Dial(network, address string) (essentials.Conn, error) { @@ -37,7 +38,7 @@ func (n *network) DialContext(ctx context.Context, network, address string) (ess tcpConn := conn.(*net.TCPConn) - return tcpConn, setCommonSocketOptions(tcpConn) + return tcpConn, setCommonSocketOptions(tcpConn, n.keepAliveConfig) } func (n *network) MakeHTTPClient( @@ -71,6 +72,7 @@ func New( tcpTimeout, httpTimeout, idleTimeout time.Duration, + keepAliveConfig net.KeepAliveConfig, ) mtglib.Network { if dnsResolver == nil { dnsResolver = net.DefaultResolver @@ -86,8 +88,9 @@ func New( Resolver: dnsResolver, FallbackDelay: -1, }, - userAgent: userAgent, - idleTimeout: idleTimeout, - httpTimeout: httpTimeout, + userAgent: userAgent, + idleTimeout: idleTimeout, + httpTimeout: httpTimeout, + keepAliveConfig: keepAliveConfig, } } diff --git a/network/v2/sockopts.go b/network/v2/sockopts.go index 378db00..933c76f 100644 --- a/network/v2/sockopts.go +++ b/network/v2/sockopts.go @@ -5,9 +5,9 @@ import ( "net" ) -func setCommonSocketOptions(conn *net.TCPConn) error { - if err := conn.SetKeepAlivePeriod(DefaultTCPKeepAlivePeriod); err != nil { - return fmt.Errorf("cannot set time period of TCP keepalive probes: %w", err) +func setCommonSocketOptions(conn *net.TCPConn, keepAliveConfig net.KeepAliveConfig) error { + if err := conn.SetKeepAliveConfig(keepAliveConfig); err != nil { + return fmt.Errorf("cannot configure TCP keepalive: %w", err) } if err := conn.SetLinger(tcpLingerTimeout); err != nil { @@ -23,5 +23,9 @@ func setCommonSocketOptions(conn *net.TCPConn) error { return fmt.Errorf("cannot setup SO_REUSEADDR/PORT: %w", err) } + setCongestionControl(rawConn) + setTCPUserTimeout(rawConn, keepAliveConfig) + setNotSentLowat(rawConn) + return nil } diff --git a/network/v2/sockopts_congestion.go b/network/v2/sockopts_congestion.go new file mode 100644 index 0000000..30c7e12 --- /dev/null +++ b/network/v2/sockopts_congestion.go @@ -0,0 +1,20 @@ +//go:build linux + +package network + +import ( + "syscall" + + "golang.org/x/sys/unix" +) + +// setCongestionControl sets BBR as the TCP congestion control algorithm. +// BBR provides better throughput over lossy and high-latency links compared +// to the default cubic, which is especially beneficial for mobile and +// home internet clients. This is best-effort: silently ignored if the +// kernel does not have tcp_bbr available. +func setCongestionControl(conn syscall.RawConn) { + conn.Control(func(fd uintptr) { //nolint: errcheck + unix.SetsockoptString(int(fd), unix.IPPROTO_TCP, unix.TCP_CONGESTION, "bbr") //nolint: errcheck + }) +} diff --git a/network/v2/sockopts_congestion_stub.go b/network/v2/sockopts_congestion_stub.go new file mode 100644 index 0000000..bb7540c --- /dev/null +++ b/network/v2/sockopts_congestion_stub.go @@ -0,0 +1,7 @@ +//go:build !linux + +package network + +import "syscall" + +func setCongestionControl(conn syscall.RawConn) {} diff --git a/network/v2/sockopts_lowat.go b/network/v2/sockopts_lowat.go new file mode 100644 index 0000000..23ff936 --- /dev/null +++ b/network/v2/sockopts_lowat.go @@ -0,0 +1,20 @@ +//go:build linux || darwin + +package network + +import ( + "syscall" + + "golang.org/x/sys/unix" +) + +// setNotSentLowat sets TCP_NOTSENT_LOWAT which limits the amount of +// unsent data queued in the kernel write buffer. Once unsent data drops +// below this threshold the socket becomes writable again, applying +// back-pressure to the relay loop instead of piling up data in kernel +// buffers. This reduces per-connection memory and bufferbloat. +func setNotSentLowat(conn syscall.RawConn) { + conn.Control(func(fd uintptr) { //nolint: errcheck + unix.SetsockoptInt(int(fd), unix.IPPROTO_TCP, unix.TCP_NOTSENT_LOWAT, tcpNotSentLowat) //nolint: errcheck + }) +} diff --git a/network/v2/sockopts_lowat_stub.go b/network/v2/sockopts_lowat_stub.go new file mode 100644 index 0000000..c77aa25 --- /dev/null +++ b/network/v2/sockopts_lowat_stub.go @@ -0,0 +1,7 @@ +//go:build !linux && !darwin + +package network + +import "syscall" + +func setNotSentLowat(conn syscall.RawConn) {} diff --git a/network/v2/sockopts_unix.go b/network/v2/sockopts_reuseaddr.go similarity index 96% rename from network/v2/sockopts_unix.go rename to network/v2/sockopts_reuseaddr.go index 75df3af..29bfcb4 100644 --- a/network/v2/sockopts_unix.go +++ b/network/v2/sockopts_reuseaddr.go @@ -1,5 +1,4 @@ //go:build !windows -// +build !windows package network diff --git a/network/v2/sockopts_windows.go b/network/v2/sockopts_reuseaddr_stub.go similarity index 87% rename from network/v2/sockopts_windows.go rename to network/v2/sockopts_reuseaddr_stub.go index 32a702a..b8cd910 100644 --- a/network/v2/sockopts_windows.go +++ b/network/v2/sockopts_reuseaddr_stub.go @@ -1,5 +1,4 @@ //go:build windows -// +build windows package network diff --git a/network/v2/sockopts_test.go b/network/v2/sockopts_test.go new file mode 100644 index 0000000..939d3c2 --- /dev/null +++ b/network/v2/sockopts_test.go @@ -0,0 +1,92 @@ +//go:build linux || darwin +// +build linux darwin + +package network + +import ( + "net" + "runtime" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/require" + "golang.org/x/sys/unix" +) + +func tcpKeepIdleOption() int { + if runtime.GOOS == "darwin" { + return 0x10 // TCP_KEEPALIVE on macOS + } + + return 0x4 // TCP_KEEPIDLE on Linux +} + +func TestSetCommonSocketOptionsKeepAlive(t *testing.T) { + t.Parallel() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer func() { + err := listener.Close() + require.NoError(t, err) + }() + + type dialResult struct { + conn net.Conn + err error + } + + dialDone := make(chan dialResult, 1) + + go func() { + c, err := net.Dial("tcp", listener.Addr().String()) + dialDone <- dialResult{conn: c, err: err} + }() + + tcpListener, ok := listener.(*net.TCPListener) + require.True(t, ok, "listener must be a *net.TCPListener") + + require.NoError(t, tcpListener.SetDeadline(time.Now().Add(5*time.Second))) + + accepted, err := listener.Accept() + require.NoError(t, err) + defer func() { + err := accepted.Close() + require.NoError(t, err) + }() + + dr := <-dialDone + require.NoError(t, dr.err) + defer func() { + err := dr.conn.Close() + require.NoError(t, err) + }() + + tcpConn := accepted.(*net.TCPConn) + + err = setCommonSocketOptions(tcpConn, DefaultKeepAliveConfig) + require.NoError(t, err) + + rawConn, err := tcpConn.SyscallConn() + require.NoError(t, err) + + err = rawConn.Control(func(fd uintptr) { + val, err := unix.GetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_KEEPALIVE) + require.NoError(t, err) + require.NotEqual(t, 0, val, "SO_KEEPALIVE should be enabled") + + idle, err := unix.GetsockoptInt(int(fd), syscall.IPPROTO_TCP, tcpKeepIdleOption()) + require.NoError(t, err) + require.Equal(t, 15, idle, "keepalive idle should match DefaultKeepAliveIdle") + + interval, err := unix.GetsockoptInt(int(fd), syscall.IPPROTO_TCP, unix.TCP_KEEPINTVL) + require.NoError(t, err) + require.Equal(t, 15, interval, "keepalive interval should match DefaultKeepAliveInterval") + + count, err := unix.GetsockoptInt(int(fd), syscall.IPPROTO_TCP, unix.TCP_KEEPCNT) + require.NoError(t, err) + require.Equal(t, 9, count, "keepalive count should match DefaultKeepAliveCount") + }) + require.NoError(t, err) +} diff --git a/network/v2/sockopts_usertimeout.go b/network/v2/sockopts_usertimeout.go new file mode 100644 index 0000000..0a1b04d --- /dev/null +++ b/network/v2/sockopts_usertimeout.go @@ -0,0 +1,48 @@ +//go:build linux + +package network + +import ( + "net" + "syscall" + "time" + + "golang.org/x/sys/unix" +) + +// Go runtime defaults for KeepAliveConfig when fields are zero. +const ( + goDefaultKeepAliveIdle = 15 * time.Second + goDefaultKeepAliveInterval = 15 * time.Second + goDefaultKeepAliveCount = 9 +) + +// setTCPUserTimeout sets TCP_USER_TIMEOUT on a socket. If transmitted +// data remains unacknowledged for this long, the kernel closes the +// connection. As recommended by Cloudflare +// (https://blog.cloudflare.com/when-tcp-sockets-refuse-to-die/), +// the value is computed as: keepidle + keepintvl * keepcnt. This +// ensures TCP_USER_TIMEOUT and keepalives agree on when to give up. +// Best-effort: silently ignored if unsupported. +func setTCPUserTimeout(conn syscall.RawConn, cfg net.KeepAliveConfig) { + idle := cfg.Idle + if idle == 0 { + idle = goDefaultKeepAliveIdle + } + + interval := cfg.Interval + if interval == 0 { + interval = goDefaultKeepAliveInterval + } + + count := cfg.Count + if count == 0 { + count = goDefaultKeepAliveCount + } + + timeout := idle + interval*time.Duration(count) + + conn.Control(func(fd uintptr) { //nolint: errcheck + unix.SetsockoptInt(int(fd), unix.IPPROTO_TCP, unix.TCP_USER_TIMEOUT, int(timeout.Milliseconds())) //nolint: errcheck + }) +} diff --git a/network/v2/sockopts_usertimeout_stub.go b/network/v2/sockopts_usertimeout_stub.go new file mode 100644 index 0000000..2d026c0 --- /dev/null +++ b/network/v2/sockopts_usertimeout_stub.go @@ -0,0 +1,10 @@ +//go:build !linux + +package network + +import ( + "net" + "syscall" +) + +func setTCPUserTimeout(conn syscall.RawConn, cfg net.KeepAliveConfig) {} diff --git a/network/v2/socks_proxy_test.go b/network/v2/socks_proxy_test.go index d6d41ae..6d74b6d 100644 --- a/network/v2/socks_proxy_test.go +++ b/network/v2/socks_proxy_test.go @@ -66,7 +66,7 @@ func (suite *SocksProxyTestSuite) SetupSuite() { require.NoError(suite.T(), err) suite.authURL = parsed - suite.baseNetwork = network.New(nil, "mtg", 0, 0, 0) + suite.baseNetwork = network.New(nil, "mtg", 0, 0, 0, network.DefaultKeepAliveConfig) } func (suite *SocksProxyTestSuite) TestIncorrectSchema() {