REPOSITORY / ScuroNeko/mtg

Compare commits

DIFF REPOSITORY

Compare commits

...
24 Commits
Author SHA1 Message Date
9seconds 1a81efcb6e Merge remote-tracking branch 'origin/stable' into v2 2026-04-01 17:17:22 +02:00
9seconds 2544c521ed Merge remote-tracking branch 'origin/master' into stable 2026-04-01 17:16:55 +02:00
9seconds 3a68ea5f2d Update goreleaser 2026-04-01 17:05:30 +02:00
Sergei ArkhipovandGitHub dbced77566 Merge pull request #433 from 9seconds/refactor-tls-fragmentation
Refactor TLS fragmenting
2026-04-01 14:30:21 +02:00
9seconds f4f969e702 Refactor TLS fragmenting 2026-04-01 14:01:24 +02:00
Sergei ArkhipovandGitHub e8368f7645 Merge pull request #431 from appolimp/tls-record-reassembly-pr
Support fragmented TLS handshake records
2026-04-01 09:33:13 +02:00
appolimp 38abee7d7f Support fragmented TLS handshake records
DPI bypass tools like ByeDPI fragment a single TLS record into multiple
records to evade censorship. This broke ReadClientHello because it
assumed the entire ClientHello arrives in one TLS record.

Add reassembleTLSHandshake that reads continuation records and
reconstructs a single TLS record before parsing and HMAC verification.
Per RFC 5246 Section 6.2.1, handshake messages may be fragmented
across multiple records — this is valid TLS behavior.
2026-04-01 09:05:24 +03:00
9seconds a3663fe8b5 Increase timeout for CI artifacts build 2026-03-31 22:13:42 +02:00
9seconds 2aa3321bd4 Add more forks 2026-03-31 19:03:48 +02:00
Sergei ArkhipovandGitHub 3793558c4c Merge pull request #430 from 9seconds/golang-idiomatic
More idiomatic Golang
2026-03-31 17:23:11 +02:00
9seconds b6427ee321 More idiomatic Golang 2026-03-31 15:07:01 +02:00
Sergei ArkhipovandGitHub 0c9fa5e710 Merge pull request #428 from 9seconds/auto-update-prio{
Change IP address set priority
2026-03-31 12:56:58 +02:00
9seconds 1fcec38aea Change IP address set priority
For a couple of releases we use collected IPs as a prioritized source
for connecting to Telegram. But apparently, they work way worse than it
should, and having connectivity to core ip ALWAYS gives better results.
Thus, this PR flips priorities, so users could have auto-update enabled
as a source of secondary addresses, not primary ones
2026-03-31 11:05:49 +02:00
Sergei ArkhipovandGitHub 89930631cf Merge pull request #426 from dolonet/fix/flaky-ci-race-and-bloom 2026-03-31 07:10:00 +02:00
dolonet eedee63143 Address review: use slices.Clone, simplify concurrent test
- Replace manual make+copy with slices.Clone in Snapshot()
- Remove redundant _ = len(data); Snapshot() call alone is
  sufficient to exercise the lock under -race
2026-03-30 16:17:51 +00:00
Alexey Dolotov 73c6a3aa37 fix: tighten ScoutConnCollected encapsulation and add concurrency test
- Move error check before Snapshot() to avoid unnecessary allocation
- Update existing tests to use Snapshot() instead of direct field access
- Add TestConcurrentAddSnapshot to explicitly exercise the mutex
2026-03-30 15:05:50 +03:00
Alexey Dolotov e54d9d60d3 fix: stabilize flaky CI tests
1. Add sync.Mutex to ScoutConnCollected to eliminate data race between
   Add()/MarkWrite() in readLoop and learn() iterating results.
   Introduce Snapshot() for safe read access.

2. Increase bloom filter test size from 500 to 100000 to prevent
   false negatives from random eviction in the stable bloom filter.

3. Use Require().NoError() in TestHTTPSRequest to prevent nil-pointer
   panic on resp.Body.Close() when the request fails.

Fixes #425
2026-03-30 14:50:32 +03:00
9seconds db2e6031a3 Merge remote-tracking branch 'origin/stable' into v2 2026-03-30 13:09:05 +02:00
9seconds 4b8da719ae Merge remote-tracking branch 'origin/master' into stable 2026-03-30 13:08:51 +02:00
9seconds a2de52f071 Update PGO 2026-03-30 13:08:32 +02:00
Sergei ArkhipovandGitHub b926a0590c Merge pull request #424 from dolonet/fix/relay-idle-timeout-shared-tracker
fix: use shared idle tracker for relay connections
2026-03-30 12:54:51 +02:00
Alexey Dolotov 58e8c8f982 ci: trigger tests 2026-03-30 13:51:17 +03:00
Alexey Dolotov 4642546b35 test: add idleTracker and connIdleTimeout tests
Cover shared idle tracker behavior:
- tracker lifecycle (new, idle after timeout, touch resets)
- read/write with data touches tracker
- read retries on timeout when tracker is not idle
- read closes on timeout when tracker is idle
- shared tracker prevents false timeout across directions
2026-03-30 13:21:51 +03:00
Alexey Dolotov 4627910238 fix: use shared idle tracker for relay connections
connIdleTimeout previously set per-direction deadlines independently.
During media downloads the client→telegram direction can be idle at the
application level while telegram→client is actively streaming data.
After IdleTimeout (default 1 min) the idle direction's ReadDeadline
fires, tearing down the entire relay and breaking media transfers.

Replace the per-direction timeout with a shared atomic timestamp that
both pump goroutines update on any successful Read or Write. When a
ReadDeadline fires on the idle direction, we check the shared tracker:
if the other direction was recently active, we retry instead of closing.
The connection is only torn down when both directions are idle for the
full timeout period.

This matches the documented IdleTimeout contract: "if we have any
message which will pass to either direction, a timer is reset."

Overhead: one atomic.Int64 (8 bytes) per connection pair, one
atomic.Store (~1 ns) per Read/Write with data, zero extra goroutines.

Fixes #423
2026-03-30 10:34:16 +03:00
20 changed files with 762 additions and 120 deletions
+1 -1
View File
@@ -122,7 +122,7 @@ jobs:
artifacts:
name: Build release artifacts
runs-on: ubuntu-latest
timeout-minutes: 10
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v6
+1 -1
View File
@@ -33,7 +33,7 @@ RUN go mod download
COPY . /app
RUN set -x \
&& version="$(git describe --exact-match HEAD 2>/dev/null || git describe --tags --always)" \
&& version="$(git describe --exact-match HEAD 2>/dev/null || git describe --tags --always 2>/dev/null || echo dev)" \
&& go build \
-trimpath \
-mod=readonly \
+2
View File
@@ -29,6 +29,8 @@ are the most notable:
* [Official](https://github.com/TelegramMessenger/MTProxy)
* [Python](https://github.com/alexbers/mtprotoproxy)
* [Erlang](https://github.com/seriyps/mtproto_proxy)
* [Teleproxy (C)](https://github.com/teleproxy/teleproxy)
* [mtproto.zig (Zig)](https://github.com/sleep3r/mtproto.zig)
* [Telemt (Rust)](https://github.com/telemt/telemt)
You can use any of these. They work great and all implementations have
+1 -1
View File
@@ -12,7 +12,7 @@ type StableBloomFilterTestSuite struct {
}
func (suite *StableBloomFilterTestSuite) TestOp() {
filter := antireplay.NewStableBloomFilter(500, 0.001)
filter := antireplay.NewStableBloomFilter(100000, 0.001)
suite.False(filter.SeenBefore([]byte{1, 2, 3}))
suite.False(filter.SeenBefore([]byte{4, 5, 6}))
BIN
View File
Binary file not shown.
+16 -16
View File
@@ -82,40 +82,40 @@ checksum = "sha256:4932cfca5e75bf60fe1c576edf459e5e809e6644664a068185d64b84af3fa
url = "https://github.com/golangci/golangci-lint/releases/download/v2.11.4/golangci-lint-2.11.4-windows-amd64.zip"
[[tools.goreleaser]]
version = "2.14.3"
version = "2.15.2"
backend = "aqua:goreleaser/goreleaser"
[tools.goreleaser."platforms.linux-arm64"]
checksum = "sha256:581a10e53c1176b3e81ee45cf531e02dbf899db0bc7b795669347df4276ce948"
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.14.3/goreleaser_Linux_arm64.tar.gz"
checksum = "sha256:5db66761a98f6693161e49e1a95d28d2673a892ba60cb4a5e16736cafd41c4c9"
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Linux_arm64.tar.gz"
provenance = "cosign"
[tools.goreleaser."platforms.linux-arm64-musl"]
checksum = "sha256:581a10e53c1176b3e81ee45cf531e02dbf899db0bc7b795669347df4276ce948"
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.14.3/goreleaser_Linux_arm64.tar.gz"
checksum = "sha256:5db66761a98f6693161e49e1a95d28d2673a892ba60cb4a5e16736cafd41c4c9"
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Linux_arm64.tar.gz"
provenance = "cosign"
[tools.goreleaser."platforms.linux-x64"]
checksum = "sha256:dc7faeeeb6da8bdfda788626263a4ae725892a8c7504b975c3234127d4a44579"
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.14.3/goreleaser_Linux_x86_64.tar.gz"
checksum = "sha256:0ebdbf0353aba566b969dde746cc4e4806f96c27aa2f3971b229a9df7611fedc"
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Linux_x86_64.tar.gz"
provenance = "cosign"
[tools.goreleaser."platforms.linux-x64-musl"]
checksum = "sha256:dc7faeeeb6da8bdfda788626263a4ae725892a8c7504b975c3234127d4a44579"
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.14.3/goreleaser_Linux_x86_64.tar.gz"
checksum = "sha256:0ebdbf0353aba566b969dde746cc4e4806f96c27aa2f3971b229a9df7611fedc"
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Linux_x86_64.tar.gz"
provenance = "cosign"
[tools.goreleaser."platforms.macos-arm64"]
checksum = "sha256:3507798489e107a78aff36b169de48148a335ac26eb3161608d905f3f3a957bd"
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.14.3/goreleaser_Darwin_all.tar.gz"
provenance = "cosign"
checksum = "sha256:0e6bd67688ac949780bf1166813a91f89856898ef4c40d7d46c2c74ebaa4b9ee"
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Darwin_all.tar.gz"
provenance = "github-attestations"
[tools.goreleaser."platforms.macos-x64"]
checksum = "sha256:3507798489e107a78aff36b169de48148a335ac26eb3161608d905f3f3a957bd"
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.14.3/goreleaser_Darwin_all.tar.gz"
checksum = "sha256:0e6bd67688ac949780bf1166813a91f89856898ef4c40d7d46c2c74ebaa4b9ee"
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Darwin_all.tar.gz"
provenance = "cosign"
[tools.goreleaser."platforms.windows-x64"]
checksum = "sha256:3deea8ff471aa258a2d99f3e5302971d7028647ae8ddaf103257a8113e485a31"
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.14.3/goreleaser_Windows_x86_64.zip"
checksum = "sha256:7459832946dbe122c144f8d7f87484d8572ca005b779310aa6bb03346e8de17a"
url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Windows_x86_64.zip"
provenance = "cosign"
+53 -8
View File
@@ -3,9 +3,11 @@ package mtglib
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net"
"sync/atomic"
"time"
"github.com/9seconds/mtg/v2/essentials"
@@ -97,20 +99,63 @@ func newConnProxyProtocol(source, target essentials.Conn) *connProxyProtocol {
}
}
type connIdleTimeout struct {
essentials.Conn
// idleTracker is a shared idle tracker for a pair of relay connections.
// Both directions update the same timestamp so that activity in one direction
// prevents the other (idle) direction from timing out.
type idleTracker struct {
lastActive atomic.Pointer[time.Time]
timeout time.Duration
}
func (c connIdleTimeout) Read(b []byte) (int, error) {
c.SetReadDeadline(time.Now().Add(c.timeout)) //nolint: errcheck
func newIdleTracker(timeout time.Duration) *idleTracker {
t := &idleTracker{timeout: timeout}
t.touch()
return c.Conn.Read(b) //nolint: wrapcheck
return t
}
func (t *idleTracker) touch() {
stamp := time.Now()
t.lastActive.Store(&stamp)
}
func (t *idleTracker) isIdle() bool {
return time.Since(*t.lastActive.Load()) >= t.timeout
}
type connIdleTimeout struct {
essentials.Conn
tracker *idleTracker
}
func (c connIdleTimeout) Read(b []byte) (int, error) {
var netErr net.Error
for {
c.SetReadDeadline(time.Now().Add(c.tracker.timeout)) //nolint: errcheck
n, err := c.Conn.Read(b)
switch {
case err == nil:
c.tracker.touch()
return n, nil
case errors.As(err, &netErr) && netErr.Timeout() && !c.tracker.isIdle():
continue
}
return n, err
}
}
func (c connIdleTimeout) Write(b []byte) (int, error) {
c.SetWriteDeadline(time.Now().Add(c.timeout)) //nolint: errcheck
c.SetWriteDeadline(time.Now().Add(c.tracker.timeout)) //nolint: errcheck
return c.Conn.Write(b) //nolint: wrapcheck
n, err := c.Conn.Write(b)
if n > 0 {
c.tracker.touch()
}
return n, err //nolint: wrapcheck
}
+151
View File
@@ -16,6 +16,12 @@ import (
"github.com/stretchr/testify/suite"
)
type netTimeoutError struct{}
func (e netTimeoutError) Error() string { return "i/o timeout" }
func (e netTimeoutError) Timeout() bool { return true }
func (e netTimeoutError) Temporary() bool { return true }
type ConnRewindBaseConn struct {
testlib.EssentialsConnMock
@@ -291,6 +297,141 @@ func (suite *ConnProxyProtocolTestSuite) TearDownTest() {
suite.targetConnMock.AssertExpectations(suite.T())
}
type IdleTrackerTestSuite struct {
suite.Suite
}
func (suite *IdleTrackerTestSuite) TestNewNotIdle() {
tracker := newIdleTracker(time.Second)
suite.False(tracker.isIdle())
}
func (suite *IdleTrackerTestSuite) TestIdleAfterTimeout() {
tracker := newIdleTracker(10 * time.Millisecond)
time.Sleep(20 * time.Millisecond)
suite.True(tracker.isIdle())
}
func (suite *IdleTrackerTestSuite) TestTouchResetsIdle() {
tracker := newIdleTracker(50 * time.Millisecond)
time.Sleep(30 * time.Millisecond)
tracker.touch()
suite.False(tracker.isIdle())
}
type ConnIdleTimeoutTestSuite struct {
suite.Suite
connMock *testlib.EssentialsConnMock
tracker *idleTracker
conn connIdleTimeout
}
func (suite *ConnIdleTimeoutTestSuite) SetupTest() {
suite.connMock = &testlib.EssentialsConnMock{}
suite.tracker = newIdleTracker(time.Second)
suite.conn = connIdleTimeout{
Conn: suite.connMock,
tracker: suite.tracker,
}
}
func (suite *ConnIdleTimeoutTestSuite) TearDownTest() {
suite.connMock.AssertExpectations(suite.T())
}
func (suite *ConnIdleTimeoutTestSuite) TestReadOk() {
suite.connMock.On("SetReadDeadline", mock.Anything).Return(nil)
suite.connMock.On("Read", mock.Anything).Once().Return(5, nil)
n, err := suite.conn.Read(make([]byte, 10))
suite.NoError(err)
suite.Equal(5, n)
}
func (suite *ConnIdleTimeoutTestSuite) TestReadNonTimeoutErr() {
suite.connMock.On("SetReadDeadline", mock.Anything).Return(nil)
suite.connMock.On("Read", mock.Anything).Once().Return(0, io.EOF)
n, err := suite.conn.Read(make([]byte, 10))
suite.True(errors.Is(err, io.EOF))
suite.Equal(0, n)
}
func (suite *ConnIdleTimeoutTestSuite) TestReadTimeoutRetriesWhenNotIdle() {
suite.connMock.On("SetReadDeadline", mock.Anything).Return(nil)
suite.connMock.On("Read", mock.Anything).Once().Return(0, netTimeoutError{})
suite.connMock.On("Read", mock.Anything).Once().Return(5, nil)
n, err := suite.conn.Read(make([]byte, 10))
suite.NoError(err)
suite.Equal(5, n)
}
func (suite *ConnIdleTimeoutTestSuite) TestReadTimeoutClosesWhenIdle() {
suite.tracker = newIdleTracker(time.Millisecond)
suite.conn = connIdleTimeout{
Conn: suite.connMock,
tracker: suite.tracker,
}
time.Sleep(5 * time.Millisecond)
suite.connMock.On("SetReadDeadline", mock.Anything).Return(nil)
suite.connMock.On("Read", mock.Anything).Once().Return(0, netTimeoutError{})
n, err := suite.conn.Read(make([]byte, 10))
suite.Equal(0, n)
netErr, ok := err.(net.Error) //nolint: errorlint
suite.True(ok)
suite.True(netErr.Timeout())
}
func (suite *ConnIdleTimeoutTestSuite) TestSharedTrackerPreventsFalseTimeout() {
connMock2 := &testlib.EssentialsConnMock{}
conn2 := connIdleTimeout{
Conn: connMock2,
tracker: suite.tracker,
}
connMock2.On("SetWriteDeadline", mock.Anything).Return(nil)
connMock2.On("Write", mock.Anything).Once().Return(5, nil)
_, _ = conn2.Write(make([]byte, 5))
suite.connMock.On("SetReadDeadline", mock.Anything).Return(nil)
suite.connMock.On("Read", mock.Anything).Once().Return(0, netTimeoutError{})
suite.connMock.On("Read", mock.Anything).Once().Return(3, nil)
n, err := suite.conn.Read(make([]byte, 10))
suite.NoError(err)
suite.Equal(3, n)
connMock2.AssertExpectations(suite.T())
}
func (suite *ConnIdleTimeoutTestSuite) TestWriteOk() {
suite.connMock.On("SetWriteDeadline", mock.Anything).Return(nil)
suite.connMock.On("Write", mock.Anything).Once().Return(5, nil)
n, err := suite.conn.Write(make([]byte, 5))
suite.NoError(err)
suite.Equal(5, n)
}
func (suite *ConnIdleTimeoutTestSuite) TestWriteErr() {
suite.connMock.On("SetWriteDeadline", mock.Anything).Return(nil)
suite.connMock.On("Write", mock.Anything).Once().Return(0, io.EOF)
n, err := suite.conn.Write(make([]byte, 5))
suite.True(errors.Is(err, io.EOF))
suite.Equal(0, n)
}
func TestConnTraffic(t *testing.T) {
t.Parallel()
suite.Run(t, &ConnTrafficTestSuite{})
@@ -305,3 +446,13 @@ func TestConnProxyProtocol(t *testing.T) {
t.Parallel()
suite.Run(t, &ConnProxyProtocolTestSuite{})
}
func TestIdleTracker(t *testing.T) {
t.Parallel()
suite.Run(t, &IdleTrackerTestSuite{})
}
func TestConnIdleTimeout(t *testing.T) {
t.Parallel()
suite.Run(t, &ConnIdleTimeoutTestSuite{})
}
+6 -2
View File
@@ -5,15 +5,19 @@ type dcView struct {
}
func (d dcView) getV4(dc int) []Addr {
addrs := d.publicConfigs.getV4(dc)
var addrs []Addr
addrs = append(addrs, defaultDCAddrSet.getV4(dc)...)
addrs = append(addrs, d.publicConfigs.getV4(dc)...)
return addrs
}
func (d dcView) getV6(dc int) []Addr {
addrs := d.publicConfigs.getV6(dc)
var addrs []Addr
addrs = append(addrs, defaultDCAddrSet.getV6(dc)...)
addrs = append(addrs, d.publicConfigs.getV6(dc)...)
return addrs
}
+12 -6
View File
@@ -61,23 +61,29 @@ func (s Scout) learn(ctx context.Context, url string) (ScoutResult, error) {
client.CloseIdleConnections()
}
if err != nil || len(results.data) == 0 {
if err != nil {
return ScoutResult{}, err
}
data, writeIndex := results.Snapshot()
if len(data) == 0 {
return ScoutResult{}, nil
}
var result ScoutResult
// Compute inter-record durations (existing logic).
lastTimestamp := time.Time{}
for i, v := range results.data {
for i, v := range data {
if v.recordType != tls.TypeApplicationData {
continue
}
if lastTimestamp.IsZero() {
if i > 0 {
lastTimestamp = results.data[i-1].timestamp
lastTimestamp = data[i-1].timestamp
} else {
lastTimestamp = v.timestamp
}
@@ -90,12 +96,12 @@ func (s Scout) learn(ctx context.Context, url string) (ScoutResult, error) {
// Compute cert size: sum of ApplicationData payload between CCS and
// the first client Write (which marks the end of server handshake).
seenCCS := false
boundary := results.writeIndex
boundary := writeIndex
if boundary < 0 {
boundary = len(results.data)
boundary = len(data)
}
for i, v := range results.data {
for i, v := range data {
if i >= boundary {
break
}
+20 -1
View File
@@ -1,6 +1,10 @@
package doppel
import "time"
import (
"slices"
"sync"
"time"
)
const (
ScoutConnCollectedPreallocSize = 100
@@ -13,23 +17,38 @@ type ScoutConnResult struct {
}
type ScoutConnCollected struct {
mu sync.Mutex
data []ScoutConnResult
writeIndex int // index at which client first wrote post-handshake data; -1 if not set
}
func (s *ScoutConnCollected) Add(record byte, payloadLen int) {
s.mu.Lock()
s.data = append(s.data, ScoutConnResult{
timestamp: time.Now(),
recordType: record,
payloadLen: payloadLen,
})
s.mu.Unlock()
}
// MarkWrite records the current data length as the handshake boundary.
func (s *ScoutConnCollected) MarkWrite() {
s.mu.Lock()
if s.writeIndex < 0 {
s.writeIndex = len(s.data)
}
s.mu.Unlock()
}
// Snapshot returns a copy of the collected data and the write index.
func (s *ScoutConnCollected) Snapshot() ([]ScoutConnResult, int) {
s.mu.Lock()
snapshot := slices.Clone(s.data)
writeIndex := s.writeIndex
s.mu.Unlock()
return snapshot, writeIndex
}
func NewScoutConnCollected() *ScoutConnCollected {
@@ -1,6 +1,7 @@
package doppel
import (
"sync"
"testing"
"time"
@@ -16,8 +17,10 @@ func (suite *ScoutConnCollectedTestSuite) TestAddSingle() {
collected := NewScoutConnCollected()
collected.Add(tls.TypeApplicationData, 100)
suite.Len(collected.data, 1)
suite.Equal(byte(tls.TypeApplicationData), collected.data[0].recordType)
data, _ := collected.Snapshot()
suite.Len(data, 1)
suite.Equal(byte(tls.TypeApplicationData), data[0].recordType)
}
func (suite *ScoutConnCollectedTestSuite) TestAddTimestampsAreMonotonic() {
@@ -31,11 +34,52 @@ func (suite *ScoutConnCollectedTestSuite) TestAddTimestampsAreMonotonic() {
time.Sleep(time.Microsecond)
collected.Add(tls.TypeApplicationData, 100)
for i := 1; i < len(collected.data); i++ {
suite.True(collected.data[i].timestamp.After(collected.data[i-1].timestamp))
data, _ := collected.Snapshot()
for i := 1; i < len(data); i++ {
suite.True(data[i].timestamp.After(data[i-1].timestamp))
}
}
func (suite *ScoutConnCollectedTestSuite) TestConcurrentAddSnapshot() {
collected := NewScoutConnCollected()
var wg sync.WaitGroup
wg.Add(3)
go func() {
defer wg.Done()
for i := 0; i < 1000; i++ {
collected.Add(tls.TypeApplicationData, i)
}
}()
go func() {
defer wg.Done()
for i := 0; i < 100; i++ {
collected.MarkWrite()
}
}()
go func() {
defer wg.Done()
for i := 0; i < 1000; i++ {
// call Snapshot concurrently to exercise the lock under -race
collected.Snapshot() //nolint:errcheck
}
}()
wg.Wait()
data, writeIndex := collected.Snapshot()
suite.Len(data, 1000)
suite.GreaterOrEqual(writeIndex, 0)
}
func TestScoutConnCollected(t *testing.T) {
t.Parallel()
suite.Run(t, &ScoutConnCollectedTestSuite{})
+7 -69
View File
@@ -11,8 +11,6 @@ import (
"net"
"slices"
"time"
"github.com/9seconds/mtg/v2/mtglib/internal/tls"
)
const (
@@ -56,25 +54,17 @@ func ReadClientHello(
// 4. New digest should be all 0 except of last 4 bytes
// 5. Last 4 bytes are little endian uint32 of UNIX timestamp when
// this message was created.
handshakeCopyBuf := &bytes.Buffer{}
reader := io.TeeReader(conn, handshakeCopyBuf)
reader, err := parseTLSHeader(reader)
clientHelloCopy, handshakeReader, err := parseClientHello(conn)
if err != nil {
return nil, fmt.Errorf("cannot parse tls header: %w", err)
return nil, fmt.Errorf("cannot read client hello: %w", err)
}
reader, err = parseHandshakeHeader(reader)
if err != nil {
return nil, fmt.Errorf("cannot parse handshake header: %w", err)
}
hello, err := parseHandshake(reader)
hello, err := parseHandshake(handshakeReader)
if err != nil {
return nil, fmt.Errorf("cannot parse handshake: %w", err)
}
sniHostnames, err := parseSNI(reader)
sniHostnames, err := parseSNI(handshakeReader)
if err != nil {
return nil, fmt.Errorf("cannot parse SNI: %w", err)
}
@@ -85,10 +75,10 @@ func ReadClientHello(
digest := hmac.New(sha256.New, secret)
// we write a copy of the handshake with client random all nullified.
digest.Write(handshakeCopyBuf.Next(RandomOffset))
handshakeCopyBuf.Next(RandomLen)
digest.Write(clientHelloCopy.Next(RandomOffset))
clientHelloCopy.Next(RandomLen)
digest.Write(emptyRandom[:])
digest.Write(handshakeCopyBuf.Bytes())
digest.Write(clientHelloCopy.Bytes())
computed := digest.Sum(nil)
@@ -110,58 +100,6 @@ func ReadClientHello(
return hello, nil
}
func parseTLSHeader(r io.Reader) (io.Reader, error) {
// record_type(1) + version(2) + size(2)
// 16 - type is 0x16 (handshake record)
// 03 01 - protocol version is "3,1" (also known as TLS 1.0)
// 00 f8 - 0xF8 (248) bytes of handshake message follows
header := [1 + 2 + 2]byte{}
if _, err := io.ReadFull(r, header[:]); err != nil {
return nil, fmt.Errorf("cannot read record header: %w", err)
}
if header[0] != tls.TypeHandshake {
return nil, fmt.Errorf("unexpected record type %#x", header[0])
}
if header[1] != 3 || header[2] != 1 {
return nil, fmt.Errorf("unexpected protocol version %#x %#x", header[1], header[2])
}
length := int64(binary.BigEndian.Uint16(header[3:]))
buf := &bytes.Buffer{}
_, err := io.CopyN(buf, r, length)
return buf, err
}
func parseHandshakeHeader(r io.Reader) (io.Reader, error) {
// type(1) + size(3 / uint24)
// 01 - handshake message type 0x01 (client hello)
// 00 00 f4 - 0xF4 (244) bytes of client hello data follows
header := [1 + 3]byte{}
if _, err := io.ReadFull(r, header[:]); err != nil {
return nil, fmt.Errorf("cannot read handshake header: %w", err)
}
if header[0] != TypeHandshakeClient {
return nil, fmt.Errorf("incorrect handshake type: %#x", header[0])
}
// unfortunately there is not uint24 in golang, so we just reust header
header[0] = 0
length := int64(binary.BigEndian.Uint32(header[:]))
buf := &bytes.Buffer{}
_, err := io.CopyN(buf, r, length)
return buf, err
}
func parseHandshake(r io.Reader) (*ClientHello, error) {
// A protocol version of "3,3" (meaning TLS 1.2) is given.
header := [2]byte{}
@@ -3,8 +3,10 @@ package fake_test
import (
"bytes"
"encoding/binary"
"encoding/json"
"errors"
"io"
"os"
"testing"
"time"
@@ -393,3 +395,273 @@ func TestParseClientHelloSNI(t *testing.T) {
t.Parallel()
suite.Run(t, &ParseClientHelloSNITestSuite{})
}
// fragmentTLSRecord splits a single TLS record into n TLS records by
// dividing the payload into roughly equal parts. Each part gets its own
// TLS record header with the same record type and version.
func fragmentTLSRecord(t testing.TB, full []byte, n int) []byte {
t.Helper()
recordType := full[0]
version := full[1:3]
payload := full[tls.SizeHeader:]
chunkSize := len(payload) / n
result := &bytes.Buffer{}
for i := 0; i < n; i++ {
start := i * chunkSize
end := start + chunkSize
if i == n-1 {
end = len(payload)
}
chunk := payload[start:end]
result.WriteByte(recordType)
result.Write(version)
require.NoError(t, binary.Write(result, binary.BigEndian, uint16(len(chunk))))
result.Write(chunk)
}
return result.Bytes()
}
// splitPayloadAt creates two TLS records from a single record by splitting
// the payload at the given byte position.
func splitPayloadAt(t testing.TB, full []byte, pos int) []byte {
t.Helper()
payload := full[tls.SizeHeader:]
buf := &bytes.Buffer{}
buf.WriteByte(tls.TypeHandshake)
buf.Write(full[1:3])
require.NoError(t, binary.Write(buf, binary.BigEndian, uint16(pos)))
buf.Write(payload[:pos])
buf.WriteByte(tls.TypeHandshake)
buf.Write(full[1:3])
require.NoError(t, binary.Write(buf, binary.BigEndian, uint16(len(payload)-pos)))
buf.Write(payload[pos:])
return buf.Bytes()
}
type ParseClientHelloFragmentedTestSuite struct {
suite.Suite
secret mtglib.Secret
snapshot *clientHelloSnapshot
}
func (s *ParseClientHelloFragmentedTestSuite) SetupSuite() {
parsed, err := mtglib.ParseSecret(
"ee367a189aee18fa31c190054efd4a8e9573746f726167652e676f6f676c65617069732e636f6d",
)
require.NoError(s.T(), err)
s.secret = parsed
fileData, err := os.ReadFile("testdata/client-hello-ok-19dfe38384b9884b.json")
require.NoError(s.T(), err)
s.snapshot = &clientHelloSnapshot{}
require.NoError(s.T(), json.Unmarshal(fileData, s.snapshot))
}
func (s *ParseClientHelloFragmentedTestSuite) makeConn(data []byte) *parseClientHelloConnMock {
readBuf := &bytes.Buffer{}
readBuf.Write(data)
connMock := &parseClientHelloConnMock{
readBuf: readBuf,
}
connMock.
On("SetReadDeadline", mock.AnythingOfType("time.Time")).
Twice().
Return(nil)
return connMock
}
func (s *ParseClientHelloFragmentedTestSuite) TestReassemblySuccess() {
full := s.snapshot.GetFull()
tests := []struct {
name string
data []byte
}{
{"two equal fragments", fragmentTLSRecord(s.T(), full, 2)},
{"three equal fragments", fragmentTLSRecord(s.T(), full, 3)},
{"single byte first fragment", splitPayloadAt(s.T(), full, 1)},
{"three byte first fragment", splitPayloadAt(s.T(), full, 3)},
}
for _, tt := range tests {
s.Run(tt.name, func() {
connMock := s.makeConn(tt.data)
defer connMock.AssertExpectations(s.T())
hello, err := fake.ReadClientHello(
connMock,
s.secret.Key[:],
s.secret.Host,
TolerateTime,
)
s.Require().NoError(err)
s.Equal(s.snapshot.GetRandom(), hello.Random[:])
s.Equal(s.snapshot.GetSessionID(), hello.SessionID)
s.Equal(uint16(s.snapshot.CipherSuite), hello.CipherSuite)
})
}
}
func (s *ParseClientHelloFragmentedTestSuite) TestReassemblyErrors() {
full := s.snapshot.GetFull()
payload := full[tls.SizeHeader:]
tests := []struct {
name string
buildData func() []byte
errMsg string
}{
{
name: "wrong continuation record type",
buildData: func() []byte {
buf := &bytes.Buffer{}
buf.WriteByte(tls.TypeHandshake)
buf.Write(full[1:3])
require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(10)))
buf.Write(payload[:10])
// Wrong type: application data instead of handshake
buf.WriteByte(tls.TypeApplicationData)
buf.Write(full[1:3])
require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(len(payload)-10)))
buf.Write(payload[10:])
return buf.Bytes()
},
errMsg: "unexpected record type",
},
{
name: "too many continuation records",
buildData: func() []byte {
// Handshake header claiming 256 bytes, but we only send 1 byte per continuation
handshakePayload := []byte{0x01, 0x00, 0x01, 0x00}
buf := &bytes.Buffer{}
buf.WriteByte(tls.TypeHandshake)
buf.Write([]byte{3, 1})
require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(len(handshakePayload))))
buf.Write(handshakePayload)
for range 11 {
buf.WriteByte(tls.TypeHandshake)
buf.Write([]byte{3, 1})
require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(1)))
buf.WriteByte(0xAB)
}
return buf.Bytes()
},
errMsg: "too many fragments",
},
{
name: "zero-length continuation record",
buildData: func() []byte {
buf := &bytes.Buffer{}
buf.WriteByte(tls.TypeHandshake)
buf.Write(full[1:3])
require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(10)))
buf.Write(payload[:10])
// Valid header but zero-length payload
buf.WriteByte(tls.TypeHandshake)
buf.Write(full[1:3])
require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(0)))
return buf.Bytes()
},
errMsg: "cannot read record header",
},
{
name: "wrong continuation record version",
buildData: func() []byte {
buf := &bytes.Buffer{}
buf.WriteByte(tls.TypeHandshake)
buf.Write(full[1:3])
require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(10)))
buf.Write(payload[:10])
// Wrong version: 3.3 instead of 3.1
buf.WriteByte(tls.TypeHandshake)
buf.Write([]byte{3, 3})
require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(len(payload)-10)))
buf.Write(payload[10:])
return buf.Bytes()
},
errMsg: "unexpected protocol version",
},
{
name: "handshake message too large",
buildData: func() []byte {
// Handshake header claiming 0x010000 (65536) bytes — exceeds 0xFFFF limit
handshakePayload := []byte{0x01, 0x01, 0x00, 0x00}
buf := &bytes.Buffer{}
buf.WriteByte(tls.TypeHandshake)
buf.Write([]byte{3, 1})
require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(len(handshakePayload))))
buf.Write(handshakePayload)
return buf.Bytes()
},
errMsg: "cannot read record header",
},
{
name: "truncated continuation record header",
buildData: func() []byte {
buf := &bytes.Buffer{}
buf.WriteByte(tls.TypeHandshake)
buf.Write(full[1:3])
require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(10)))
buf.Write(payload[:10])
// Connection ends mid-header (only 2 bytes)
buf.WriteByte(tls.TypeHandshake)
buf.WriteByte(3)
return buf.Bytes()
},
errMsg: "cannot read record header",
},
{
name: "truncated continuation record payload",
buildData: func() []byte {
buf := &bytes.Buffer{}
buf.WriteByte(tls.TypeHandshake)
buf.Write(full[1:3])
require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(10)))
buf.Write(payload[:10])
// Claims 100 bytes but no payload follows
buf.WriteByte(tls.TypeHandshake)
buf.Write(full[1:3])
require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(100)))
return buf.Bytes()
},
errMsg: "EOF",
},
}
for _, tt := range tests {
s.Run(tt.name, func() {
connMock := s.makeConn(tt.buildData())
defer connMock.AssertExpectations(s.T())
_, err := fake.ReadClientHello(
connMock,
s.secret.Key[:],
s.secret.Host,
TolerateTime,
)
s.ErrorContains(err, tt.errMsg)
})
}
}
func TestParseClientHelloFragmented(t *testing.T) {
t.Parallel()
suite.Run(t, &ParseClientHelloFragmentedTestSuite{})
}
+158
View File
@@ -0,0 +1,158 @@
package fake
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"io"
"github.com/9seconds/mtg/v2/mtglib/internal/tls"
)
const (
maxFragmentsCount = 10
)
var ErrTooManyFragments = errors.New("too many fragments")
// https://datatracker.ietf.org/doc/html/rfc5246#section-6.2.1
// client hello can be fragmented in a series of packets:
//
// Bytes on the wire:
//
// 16 03 01 00 F8 01 00 00 F4 03 03 [32 bytes random] [session_id] [ciphers] [SNI...]
// ├─────────────┤├──────────────────────────────────────────────────────────────────┤
//
// TLS record Payload (248 bytes)
// header (5B)
//
// 16 = Handshake
// 03 01 = TLS 1.0 (record layer version)
// 00 F8 = 248 bytes follow
//
// 01 = ClientHello (handshake type)
// 00 00 F4 = 244 bytes of handshake body
// 03 03 = TLS 1.2 (actual protocol version)
// ...rest of ClientHello...
//
// Fragmented record look like:
//
// Record 1:
//
// 16 03 01 00 03 01 00 00
// ├─────────────┤├──────┤
//
// TLS header 3 bytes of payload
//
// 16 = Handshake
// 03 01 = TLS 1.0
// 00 03 = only 3 bytes follow
//
// 01 = ClientHello type
// 00 00 = first 2 bytes of the uint24 length (INCOMPLETE!)
//
// Record 2:
// 16 03 01 00 F5 F4 03 03 [32 bytes random] [session_id] [ciphers] [SNI...]
// ├─────────────┤├────────────────────────────────────────────────────────────┤
//
// TLS header remaining 245 bytes of payload
//
// 16 = Handshake
// 03 01 = TLS 1.0
// 00 F5 = 245 bytes follow
//
// F4 = last byte of uint24 length (now complete: 00 00 F4 = 244)
// 03 03 = TLS 1.2
// ...rest of ClientHello continues...
//
// So it means that there could be a series of handshake packets of different
// lengths. The goal of this function is to concatenate these fragments.
type fragmentedHandshakeReader struct {
r io.Reader
buf bytes.Buffer
readFragments int
}
func (f *fragmentedHandshakeReader) Read(p []byte) (int, error) {
if n, err := f.buf.Read(p); err == nil {
return n, nil
}
f.buf.Reset()
for f.buf.Len() == 0 {
if f.readFragments > maxFragmentsCount {
return 0, ErrTooManyFragments
}
if err := f.parseNextFragment(); err != nil {
return 0, err
}
f.readFragments++
}
return f.buf.Read(p)
}
func (f *fragmentedHandshakeReader) parseNextFragment() error {
// record_type(1) + version(2) + size(2)
// 16 - type is 0x16 (handshake record)
// 03 01 - protocol version is "3,1" (also known as TLS 1.0)
// 00 f8 - 0xF8 (248) bytes of handshake message follows
header := [1 + 2 + 2]byte{}
if _, err := io.ReadFull(f.r, header[:]); err != nil {
return fmt.Errorf("cannot read record header: %w", err)
}
if header[0] != tls.TypeHandshake {
return fmt.Errorf("unexpected record type %#x", header[0])
}
if header[1] != 3 || header[2] != 1 {
return fmt.Errorf("unexpected protocol version %#x %#x", header[1], header[2])
}
length := int64(binary.BigEndian.Uint16(header[3:]))
_, err := io.CopyN(&f.buf, f.r, length)
return err
}
func parseClientHello(r io.Reader) (*bytes.Buffer, *bytes.Buffer, error) {
r = &fragmentedHandshakeReader{r: r}
header := [1 + 3]byte{}
if _, err := io.ReadFull(r, header[:]); err != nil {
return nil, nil, fmt.Errorf("cannot read handshake header: %w", err)
}
if header[0] != TypeHandshakeClient {
return nil, nil, fmt.Errorf("incorrect handshake type: %#x", header[0])
}
// unfortunately there is not uint24 in golang, so we just reuse header
header[0] = 0
length := int64(binary.BigEndian.Uint32(header[:]))
clientHelloCopy := &bytes.Buffer{}
clientHelloCopy.Write([]byte{tls.TypeHandshake, 3, 1})
binary.Write( //nolint: errcheck
clientHelloCopy,
binary.BigEndian,
// 1 for handshake type
// 3 for handshake length
uint16(1+3+length),
)
clientHelloCopy.WriteByte(TypeHandshakeClient)
clientHelloCopy.Write(header[1:])
handshakeCopy := &bytes.Buffer{}
writer := io.MultiWriter(clientHelloCopy, handshakeCopy)
_, err := io.CopyN(writer, r, length)
return clientHelloCopy, handshakeCopy, err
}
+8 -4
View File
@@ -102,11 +102,13 @@ func (p *Proxy) ServeConn(conn essentials.Conn) {
return
}
tracker := newIdleTracker(p.idleTimeout)
relay.Relay(
ctx,
ctx.logger.Named("relay"),
connIdleTimeout{Conn: ctx.telegramConn, timeout: p.idleTimeout},
connIdleTimeout{Conn: ctx.clientConn, timeout: p.idleTimeout},
connIdleTimeout{Conn: ctx.telegramConn, tracker: tracker},
connIdleTimeout{Conn: ctx.clientConn, tracker: tracker},
)
}
@@ -305,11 +307,13 @@ func (p *Proxy) doDomainFronting(ctx *streamContext, conn *connRewind) {
stream: p.eventStream,
}
tracker := newIdleTracker(p.idleTimeout)
relay.Relay(
ctx,
ctx.logger.Named("domain-fronting"),
connIdleTimeout{Conn: frontConn, timeout: p.idleTimeout},
connIdleTimeout{Conn: conn, timeout: p.idleTimeout},
connIdleTimeout{Conn: frontConn, tracker: tracker},
connIdleTimeout{Conn: conn, tracker: tracker},
)
}
-1
View File
@@ -160,7 +160,6 @@ type ProxyOpts struct {
// DoppelGangerDRS defines if TLS Dynamic Record Sizing is active.
DoppelGangerDRS bool
}
func (p ProxyOpts) valid() error {
+1 -1
View File
@@ -175,7 +175,7 @@ func (suite *ProxyTestSuite) TestHTTPSRequest() {
addr := fmt.Sprintf("https://%s/headers", suite.ProxyAddress())
resp, err := client.Get(addr) //nolint: noctx
suite.NoError(err)
suite.Require().NoError(err)
defer resp.Body.Close() //nolint: errcheck