Make DRS optional

This commit is contained in:
9seconds
2026-03-13 11:04:01 +01:00
parent ea71fe81b2
commit 21d7522356
11 changed files with 98 additions and 51 deletions
+31 -32
View File
@@ -213,42 +213,20 @@ idle = "1m"
# does not really matter), while websites pump heavy content in HTTP2 streams # does not really matter), while websites pump heavy content in HTTP2 streams
# #
# It means that statistically there is a different between traffic shape: # It means that statistically there is a different between traffic shape:
# TLS packet sizes are different, delays between packets are also different. # delays between packets are also different.
# In order to avoid censorship detection based on these patterns, there is a # In order to avoid censorship detection based on these patterns, there is a
# mtg subsystem called "Doppelganger" that aims to mimic website statistics # mtg subsystem called "Doppelganger" that aims to mimic website statistics
# as close as it could. # as close as it could.
# #
# It does that by 2 ideas: # Delays between TLS packets are not constant. There are many factors
# 1. Delays between TLS packets are not constant. There are many factors # that come in play. Application should generate some response, it could
# that come in play. Application should generate some response, it could # send some headers first and stream content with chunked encoding. So
# send some headers first and stream content with chunked encoding. So # some first packets could come as soon as possible, with some delays
# some first packets could come as soon as possible, with some delays # after first ones. Such phenomenon is described by different statistic
# after first ones. Such phenomenon is described by different statistic # distribution. There are 2 distribution that describe it: lognormal
# distribution. There are 2 distribution that describe it: lognormal # distribution and Weibul distribution. Lognormal is all about steady streams
# distribution and Weibul distribution. Lognormal is all about steady streams # of heavy content like a video. Weibul is great about short bursts like
# of heavy content like a video. Weibul is great about short bursts like # user who requested a static page an a couple of images.
# user who requested a static page an a couple of images.
#
# mtg tries to adapt Weibul distribution. It comes with some sensible
# defaults that were taken from ok.ru. But when you use domain fronting,
# it always make sense to take statistics from that website. You can specify
# some urls here. mtg will crawl them from time to time, accumulate time
# series and approximates parameters for Weibul.
# 2. TLS record sizes are not random.
# https://blog.cloudflare.com/optimizing-tls-over-tcp-to-reduce-latency/
# https://aws.github.io/s2n-tls/usage-guide/ch08-record-sizes.html
#
# The idea is that huge TLS records could negatively affect performance.
# You cannot simply decrypt a part of the packet, you need to wait it
# whole, and huge packets could involve several RTTs if you do not use
# any specific software that treat TLS in a very special way. So
# servers start with small packets, usually around MTU, and ramp up
# later. This optimizes a time-to-first byte so web browsers start to
# render early.
#
# mtg uses the same technique as was introduced by Cloudflare in their
# patches to nginx 10 years ago:
# https://github.com/cloudflare/sslconfig/blob/master/patches/nginx__dynamic_tls_records.patch
[defense.doppelganger] [defense.doppelganger]
# This is a list of URLs that would be crawled by mtg to approximate delay # This is a list of URLs that would be crawled by mtg to approximate delay
# statistics. They MUST be HTTPS urls. # statistics. They MUST be HTTPS urls.
@@ -266,6 +244,27 @@ repeats-per-raid = 10
# do not change a lot, so do not expect different results if you request # do not change a lot, so do not expect different results if you request
# each 10 minutes. # each 10 minutes.
raid-each = "6h" raid-each = "6h"
# This enables dynamic tls record sizing.
#
# Some modern stacks and platforms start to use the technique that is called
# DRS. They start with small TLS packets and ramp up eventually. First packets
# are usually about MTU size, after that we get 4k and eventually max size.
# This is done with a good intention: to minimize a time to the first byte,
# so application could start doing something with the data right after first
# RTT.
#
# Apparently, about 90% of application do not employ this technique, they use
# max size always: nginx, apache, java stuff. But Golang tools, angie and
# some specific patches activate this technique.
#
# In order to mimic a real website we need to know something about software
# it uses. Usually nobody cares: openssl does 16384, Python does it, nginx
# does it. So this setting is disabled by default.
#
# https://blog.cloudflare.com/optimizing-tls-over-tcp-to-reduce-latency/
# https://aws.github.io/s2n-tls/usage-guide/ch08-record-sizes.html
# https://github.com/cloudflare/sslconfig/blob/master/patches/nginx__dynamic_tls_records.patch
drs = false
# Some countries do active probing on Telegram connections. This technique # Some countries do active probing on Telegram connections. This technique
# allows to protect from such effort. # allows to protect from such effort.
+1
View File
@@ -265,6 +265,7 @@ func runProxy(conf *config.Config, version string) error { //nolint: funlen
DoppelGangerURLs: doppelGangerURLs, DoppelGangerURLs: doppelGangerURLs,
DoppelGangerPerRaid: conf.Defense.Doppelganger.Repeats.Get(mtglib.DoppelGangerPerRaid), DoppelGangerPerRaid: conf.Defense.Doppelganger.Repeats.Get(mtglib.DoppelGangerPerRaid),
DoppelGangerEach: conf.Defense.Doppelganger.UpdateEach.Get(mtglib.DoppelGangerEach), DoppelGangerEach: conf.Defense.Doppelganger.UpdateEach.Get(mtglib.DoppelGangerEach),
DoppelGangerDRS: conf.Defense.Doppelganger.DRS.Get(false),
} }
proxy, err := mtglib.NewProxy(opts) proxy, err := mtglib.NewProxy(opts)
+1
View File
@@ -53,6 +53,7 @@ type Config struct {
URLs []TypeHttpsURL `json:"urls"` URLs []TypeHttpsURL `json:"urls"`
Repeats TypeConcurrency `json:"repeats_per_raid"` Repeats TypeConcurrency `json:"repeats_per_raid"`
UpdateEach TypeDuration `json:"raid_each"` UpdateEach TypeDuration `json:"raid_each"`
DRS TypeBool `json:"drs"`
} `json:"doppelganger"` } `json:"doppelganger"`
} `json:"defense"` } `json:"defense"`
Network struct { Network struct {
+1
View File
@@ -48,6 +48,7 @@ type tomlConfig struct {
URLs []string `toml:"urls" json:"urls,omitempty"` URLs []string `toml:"urls" json:"urls,omitempty"`
Repeats uint `toml:"repeats-per-raid" json:"repeats_per_raid,omitempty"` Repeats uint `toml:"repeats-per-raid" json:"repeats_per_raid,omitempty"`
UpdateEach string `toml:"raid-each" json:"raid_each,omitempty"` UpdateEach string `toml:"raid-each" json:"raid_each,omitempty"`
DRS bool `toml:"drs" json:"drs,omitempty"`
} `toml:"doppelganger" json:"doppelganger,omitempty"` } `toml:"doppelganger" json:"doppelganger,omitempty"`
} `toml:"defense" json:"defense,omitempty"` } `toml:"defense" json:"defense,omitempty"`
Network struct { Network struct {
+6 -1
View File
@@ -29,6 +29,8 @@ type Ganger struct {
scoutRaidEach time.Duration scoutRaidEach time.Duration
scoutRaidRepeats int scoutRaidRepeats int
drs bool
stats *Stats stats *Stats
durations []time.Duration durations []time.Duration
@@ -107,7 +109,7 @@ func (g *Ganger) run() {
g.wg.Go(func() { g.wg.Go(func() {
select { select {
case <-g.ctx.Done(): case <-g.ctx.Done():
case updatedStatsChan <- NewStats(durations): case updatedStatsChan <- NewStats(durations, g.drs):
} }
}) })
case stats := <-updatedStatsChan: case stats := <-updatedStatsChan:
@@ -152,6 +154,7 @@ func NewGanger(
scoutEach time.Duration, scoutEach time.Duration,
scoutRepeats int, scoutRepeats int,
urls []string, urls []string,
drs bool,
) *Ganger { ) *Ganger {
ctx, cancel := context.WithCancel(ctx) ctx, cancel := context.WithCancel(ctx)
@@ -169,9 +172,11 @@ func NewGanger(
logger: logger, logger: logger,
scoutRaidEach: scoutEach, scoutRaidEach: scoutEach,
scoutRaidRepeats: scoutRepeats, scoutRaidRepeats: scoutRepeats,
drs: drs,
stats: &Stats{ stats: &Stats{
k: StatsDefaultK, k: StatsDefaultK,
lambda: StatsDefaultLambda, lambda: StatsDefaultLambda,
drs: drs,
}, },
scout: NewScout(network, urls), scout: NewScout(network, urls),
connRequests: make(chan gangerConnRequest), connRequests: make(chan gangerConnRequest),
+1 -1
View File
@@ -29,7 +29,7 @@ func (suite *GangerTestSuite) SetupTest() {
On("WarningError", mock.AnythingOfType("string"), mock.Anything). On("WarningError", mock.AnythingOfType("string"), mock.Anything).
Maybe() Maybe()
suite.g = NewGanger(suite.ctx, suite.network, suite.log, time.Hour, 1, suite.urls) suite.g = NewGanger(suite.ctx, suite.network, suite.log, time.Hour, 1, suite.urls, true)
suite.g.Run() suite.g.Run()
} }
+2 -2
View File
@@ -14,8 +14,8 @@ const (
// Please see Stats description // Please see Stats description
// https://blog.cloudflare.com/optimizing-tls-over-tcp-to-reduce-latency/ // https://blog.cloudflare.com/optimizing-tls-over-tcp-to-reduce-latency/
// https://github.com/cloudflare/sslconfig/blob/master/patches/nginx__dynamic_tls_records.patch // https://github.com/cloudflare/sslconfig/blob/master/patches/nginx__dynamic_tls_records.patch
TLSRecordSizeStart = 1369 TLSRecordSizeStart = 1450
TLSRecordSizeAccel = 4229 TLSRecordSizeAccel = 4096
TLSRecordSizeMax = 16384 - tls.SizeHeader TLSRecordSizeMax = 16384 - tls.SizeHeader
TLSCounterAccelAfter = 40 TLSCounterAccelAfter = 40
+14 -3
View File
@@ -17,6 +17,9 @@ const (
// these values are taken from ok.ru. measured from moscow site. // these values are taken from ok.ru. measured from moscow site.
StatsDefaultK = 0.37846373895785335 StatsDefaultK = 0.37846373895785335
StatsDefaultLambda = 1.73177086015485 StatsDefaultLambda = 1.73177086015485
// how many bytes should we drift
DRSNoise = 100
) )
// Stats is responsible for generating values that are distributed according // Stats is responsible for generating values that are distributed according
@@ -66,6 +69,9 @@ type Stats struct {
k float64 k float64
// https://en.wikipedia.org/wiki/Scale_parameter // https://en.wikipedia.org/wiki/Scale_parameter
lambda float64 lambda float64
// Dynamic Record Sizing
drs bool
} }
func (d *Stats) Delay() time.Duration { func (d *Stats) Delay() time.Duration {
@@ -84,20 +90,24 @@ func (d *Stats) Size() int {
d.sizeCounter = 0 d.sizeCounter = 0
} }
if !d.drs {
return TLSRecordSizeMax
}
d.sizeLastRequested = time.Now() d.sizeLastRequested = time.Now()
d.sizeCounter++ d.sizeCounter++
switch { switch {
case d.sizeCounter <= TLSCounterAccelAfter: case d.sizeCounter <= TLSCounterAccelAfter:
return TLSRecordSizeStart return TLSRecordSizeStart - rand.IntN(DRSNoise)
case d.sizeCounter <= TLSCounterMaxAfter: case d.sizeCounter <= TLSCounterMaxAfter:
return TLSRecordSizeAccel return TLSRecordSizeAccel - rand.IntN(DRSNoise)
} }
return TLSRecordSizeMax return TLSRecordSizeMax
} }
func NewStats(durations []time.Duration) *Stats { func NewStats(durations []time.Duration, drs bool) *Stats {
n := float64(len(durations)) n := float64(len(durations))
// in milliseconds // in milliseconds
@@ -150,5 +160,6 @@ func NewStats(durations []time.Duration) *Stats {
return &Stats{ return &Stats{
k: k, k: k,
lambda: lambda, lambda: lambda,
drs: drs,
} }
} }
+37 -12
View File
@@ -38,7 +38,7 @@ func (suite *StatsTestSuite) TestNewStatsRecoverParameters() {
knownLambda := 100.0 knownLambda := 100.0
samples := suite.GenWeibull(knownK, knownLambda, 5000, 42) samples := suite.GenWeibull(knownK, knownLambda, 5000, 42)
stats := NewStats(samples) stats := NewStats(samples, true)
suite.InDelta(knownK, stats.k, 0.1) suite.InDelta(knownK, stats.k, 0.1)
suite.InDelta(knownLambda, stats.lambda, 5.0) suite.InDelta(knownLambda, stats.lambda, 5.0)
@@ -50,7 +50,7 @@ func (suite *StatsTestSuite) TestNewStatsExponentialCase() {
knownLambda := 50.0 knownLambda := 50.0
samples := suite.GenWeibull(knownK, knownLambda, 5000, 123) samples := suite.GenWeibull(knownK, knownLambda, 5000, 123)
stats := NewStats(samples) stats := NewStats(samples, true)
suite.InDelta(knownK, stats.k, 0.1) suite.InDelta(knownK, stats.k, 0.1)
suite.InDelta(knownLambda, stats.lambda, 5.0) suite.InDelta(knownLambda, stats.lambda, 5.0)
@@ -64,7 +64,7 @@ func (suite *StatsTestSuite) TestNewStatsSmallK() {
knownLambda := 100.0 knownLambda := 100.0
samples := suite.GenWeibull(knownK, knownLambda, 10000, 99) samples := suite.GenWeibull(knownK, knownLambda, 10000, 99)
stats := NewStats(samples) stats := NewStats(samples, true)
suite.InDelta(knownK, stats.k, 0.05) suite.InDelta(knownK, stats.k, 0.05)
suite.InDelta(knownLambda, stats.lambda, 5.0) suite.InDelta(knownLambda, stats.lambda, 5.0)
@@ -76,7 +76,7 @@ func (suite *StatsTestSuite) TestNewStatsLargeK() {
knownLambda := 200.0 knownLambda := 200.0
samples := suite.GenWeibull(knownK, knownLambda, 5000, 77) samples := suite.GenWeibull(knownK, knownLambda, 5000, 77)
stats := NewStats(samples) stats := NewStats(samples, true)
suite.InDelta(knownK, stats.k, 0.3) suite.InDelta(knownK, stats.k, 0.3)
suite.InDelta(knownLambda, stats.lambda, 5.0) suite.InDelta(knownLambda, stats.lambda, 5.0)
@@ -121,7 +121,7 @@ func (suite *StatsTestSuite) TestNewStatsRoundTrip() {
knownLambda := 80.0 knownLambda := 80.0
samples := suite.GenWeibull(knownK, knownLambda, 5000, 555) samples := suite.GenWeibull(knownK, knownLambda, 5000, 555)
stats := NewStats(samples) stats := NewStats(samples, true)
n := 50000 n := 50000
sum := 0.0 sum := 0.0
@@ -138,16 +138,17 @@ func (suite *StatsTestSuite) TestNewStatsRoundTrip() {
} }
func (suite *StatsTestSuite) TestSizeStartPhase() { func (suite *StatsTestSuite) TestSizeStartPhase() {
stats := &Stats{k: 1.0, lambda: 1.0} stats := &Stats{k: 1.0, lambda: 1.0, drs: true}
for range TLSCounterAccelAfter { for range TLSCounterAccelAfter {
size := stats.Size() size := stats.Size()
suite.Equal(TLSRecordSizeStart, size) suite.GreaterOrEqual(size, TLSRecordSizeStart-DRSNoise)
suite.LessOrEqual(size, TLSRecordSizeStart)
} }
} }
func (suite *StatsTestSuite) TestSizeAccelPhase() { func (suite *StatsTestSuite) TestSizeAccelPhase() {
stats := &Stats{k: 1.0, lambda: 1.0} stats := &Stats{k: 1.0, lambda: 1.0, drs: true}
for range TLSCounterAccelAfter { for range TLSCounterAccelAfter {
stats.Size() stats.Size()
@@ -155,12 +156,13 @@ func (suite *StatsTestSuite) TestSizeAccelPhase() {
for range TLSCounterMaxAfter - TLSCounterAccelAfter { for range TLSCounterMaxAfter - TLSCounterAccelAfter {
size := stats.Size() size := stats.Size()
suite.Equal(TLSRecordSizeAccel, size) suite.GreaterOrEqual(size, TLSRecordSizeAccel-DRSNoise)
suite.LessOrEqual(size, TLSRecordSizeAccel)
} }
} }
func (suite *StatsTestSuite) TestSizeMaxPhase() { func (suite *StatsTestSuite) TestSizeMaxPhase() {
stats := &Stats{k: 1.0, lambda: 1.0} stats := &Stats{k: 1.0, lambda: 1.0, drs: true}
for range TLSCounterMaxAfter { for range TLSCounterMaxAfter {
stats.Size() stats.Size()
@@ -173,7 +175,7 @@ func (suite *StatsTestSuite) TestSizeMaxPhase() {
} }
func (suite *StatsTestSuite) TestSizeResetsAfterInactivity() { func (suite *StatsTestSuite) TestSizeResetsAfterInactivity() {
stats := &Stats{k: 1.0, lambda: 1.0} stats := &Stats{k: 1.0, lambda: 1.0, drs: true}
// Advance past start phase. // Advance past start phase.
for range TLSCounterMaxAfter { for range TLSCounterMaxAfter {
@@ -185,7 +187,30 @@ func (suite *StatsTestSuite) TestSizeResetsAfterInactivity() {
// Simulate inactivity by backdating sizeLastRequested. // Simulate inactivity by backdating sizeLastRequested.
stats.sizeLastRequested = time.Now().Add(-TLSRecordSizeResetAfter - time.Millisecond) stats.sizeLastRequested = time.Now().Add(-TLSRecordSizeResetAfter - time.Millisecond)
suite.Equal(TLSRecordSizeStart, stats.Size()) size := stats.Size()
suite.GreaterOrEqual(size, TLSRecordSizeStart-DRSNoise)
suite.LessOrEqual(size, TLSRecordSizeStart)
}
func (suite *StatsTestSuite) TestSizeNoDRSAlwaysMax() {
stats := &Stats{k: 1.0, lambda: 1.0, drs: false}
for range TLSCounterMaxAfter + 20 {
suite.Equal(TLSRecordSizeMax, stats.Size())
}
}
func (suite *StatsTestSuite) TestSizeNoDRSIgnoresCounter() {
stats := &Stats{k: 1.0, lambda: 1.0, drs: false}
// Even after many calls, always returns max.
for range 200 {
suite.Equal(TLSRecordSizeMax, stats.Size())
}
// Inactivity has no effect either.
stats.sizeLastRequested = time.Now().Add(-TLSRecordSizeResetAfter - time.Millisecond)
suite.Equal(TLSRecordSizeMax, stats.Size())
} }
func TestStats(t *testing.T) { func TestStats(t *testing.T) {
+1
View File
@@ -345,6 +345,7 @@ func NewProxy(opts ProxyOpts) (*Proxy, error) {
opts.DoppelGangerEach, opts.DoppelGangerEach,
int(opts.DoppelGangerPerRaid), int(opts.DoppelGangerPerRaid),
opts.DoppelGangerURLs, opts.DoppelGangerURLs,
opts.DoppelGangerDRS,
), ),
configUpdater: dc.NewPublicConfigUpdater( configUpdater: dc.NewPublicConfigUpdater(
tg, tg,
+3
View File
@@ -157,6 +157,9 @@ type ProxyOpts struct {
// DoppelGangerEach defines a time period between each raid. We recommend // DoppelGangerEach defines a time period between each raid. We recommend
// to use hours here. // to use hours here.
DoppelGangerEach time.Duration DoppelGangerEach time.Duration
// DoppelGangerDRS defines if TLS Dynamic Record Sizing is active.
DoppelGangerDRS bool
} }
func (p ProxyOpts) valid() error { func (p ProxyOpts) valid() error {