Просмотр исходного кода

Add custom network latency multipliers

Rod Hynes 6 лет назад
Родитель
Сommit
0f2a2ad78e

+ 3 - 3
psiphon/common/fragmentor/fragmentor.go

@@ -57,19 +57,19 @@ type Config struct {
 // NewUpstreamConfig creates a new Config; may return nil. Specifying the PRNG
 // seed allows for optional replay of a fragmentor sequence.
 func NewUpstreamConfig(
-	p *parameters.ClientParametersSnapshot, tunnelProtocol string, seed *prng.Seed) *Config {
+	p parameters.ClientParametersAccessor, tunnelProtocol string, seed *prng.Seed) *Config {
 	return newConfig(p, true, tunnelProtocol, seed)
 }
 
 // NewDownstreamConfig creates a new Config; may return nil. Specifying the
 // PRNG seed allows for optional replay of a fragmentor sequence.
 func NewDownstreamConfig(
-	p *parameters.ClientParametersSnapshot, tunnelProtocol string, seed *prng.Seed) *Config {
+	p parameters.ClientParametersAccessor, tunnelProtocol string, seed *prng.Seed) *Config {
 	return newConfig(p, false, tunnelProtocol, seed)
 }
 
 func newConfig(
-	p *parameters.ClientParametersSnapshot,
+	p parameters.ClientParametersAccessor,
 	isUpstream bool,
 	tunnelProtocol string,
 	seed *prng.Seed) *Config {

+ 148 - 59
psiphon/common/parameters/clientParameters.go

@@ -68,6 +68,9 @@ import (
 
 const (
 	NetworkLatencyMultiplier                         = "NetworkLatencyMultiplier"
+	CustomNetworkLatencyMultiplierMin                = "CustomNetworkLatencyMultiplierMin"
+	CustomNetworkLatencyMultiplierMax                = "CustomNetworkLatencyMultiplierMax"
+	CustomNetworkLatencyMultiplierLambda             = "CustomNetworkLatencyMultiplierLambda"
 	TacticsWaitPeriod                                = "TacticsWaitPeriod"
 	TacticsRetryPeriod                               = "TacticsRetryPeriod"
 	TacticsRetryPeriodJitter                         = "TacticsRetryPeriodJitter"
@@ -242,7 +245,10 @@ var defaultClientParameters = map[string]struct {
 	// NetworkLatencyMultiplier defaults to 0, meaning off. But when set, it
 	// must be a multiplier >= 1.
 
-	NetworkLatencyMultiplier: {value: 0.0, minimum: 1.0},
+	NetworkLatencyMultiplier:             {value: 0.0, minimum: 1.0},
+	CustomNetworkLatencyMultiplierMin:    {value: 1.0, minimum: 1.0},
+	CustomNetworkLatencyMultiplierMax:    {value: 3.0, minimum: 1.0},
+	CustomNetworkLatencyMultiplierLambda: {value: 2.0, minimum: 1.0},
 
 	TacticsWaitPeriod:        {value: 10 * time.Second, minimum: 0 * time.Second, flags: useNetworkLatencyMultiplier},
 	TacticsRetryPeriod:       {value: 5 * time.Second, minimum: 1 * time.Millisecond},
@@ -466,14 +472,72 @@ type ClientParameters struct {
 	snapshot       atomic.Value
 }
 
-// ClientParametersSnapshot is an atomic snapshot of the client parameter
-// values. ClientParameters.Get will return a snapshot which may be used to
-// read multiple related values atomically and consistently while the current
-// snapshot in ClientParameters may change concurrently.
-type ClientParametersSnapshot struct {
-	getValueLogger func(error)
-	tag            string
-	parameters     map[string]interface{}
+// ClientParametersAccessor defines the interface for accessing client
+// parameter values.
+type ClientParametersAccessor interface {
+
+	// Tag returns the tag associated with these parameters.
+	Tag() string
+
+	// String returns a string parameter value.
+	String(name string) string
+
+	// Strings returns a []string parameter value.
+	Strings(name string) []string
+
+	// Int returns an int parameter value.
+	Int(name string) int
+
+	// Bool returns a bool parameter value.
+	Bool(name string) bool
+
+	// Float returns a float64 parameter value.
+	Float(name string) float64
+
+	// WeightedCoinFlip returns the result of prng.FlipWeightedCoin using the
+	// specified float parameter as the probability input.
+	WeightedCoinFlip(name string) bool
+
+	// Duration returns a time.Duration parameter value. When the duration
+	// parameter has the useNetworkLatencyMultiplier flag, the
+	// NetworkLatencyMultiplier is applied to the returned value.
+	Duration(name string) time.Duration
+
+	// TunnelProtocols returns a protocol.TunnelProtocols parameter value.
+	// If there is a corresponding Probability value, a weighted coin flip
+	// will be performed and, depending on the result, the value or the
+	// parameter default will be returned.
+	TunnelProtocols(name string) protocol.TunnelProtocols
+
+	// TLSProfiles returns a protocol.TLSProfiles parameter value.
+	// If there is a corresponding Probability value, a weighted coin flip
+	// will be performed and, depending on the result, the value or the
+	// parameter default will be returned.
+	TLSProfiles(name string) protocol.TLSProfiles
+
+	// QUICVersions returns a protocol.QUICVersions parameter value.
+	// If there is a corresponding Probability value, a weighted coin flip
+	// will be performed and, depending on the result, the value or the
+	// parameter default will be returned.
+	QUICVersions(name string) protocol.QUICVersions
+
+	// DownloadURLs returns a DownloadURLs parameter value.
+	DownloadURLs(name string) DownloadURLs
+
+	// RateLimits returns a common.RateLimits parameter value.
+	RateLimits(name string) common.RateLimits
+
+	// HTTPHeaders returns an http.Header parameter value.
+	HTTPHeaders(name string) http.Header
+
+	// CustomTLSProfileNames returns the CustomTLSProfile.Name fields for
+	// each profile in the CustomTLSProfiles parameter value.
+	CustomTLSProfileNames() []string
+
+	// CustomTLSProfile returns the CustomTLSProfile fields with the specified
+	// Name field if it exists in the CustomTLSProfiles parameter value.
+	// Returns nil if not found.
+	CustomTLSProfile(name string) *protocol.CustomTLSProfile
 }
 
 // NewClientParameters initializes a new ClientParameters with the default
@@ -690,7 +754,7 @@ func (p *ClientParameters) Set(
 		counts = append(counts, count)
 	}
 
-	snapshot := &ClientParametersSnapshot{
+	snapshot := &clientParametersSnapshot{
 		getValueLogger: p.getValueLogger,
 		tag:            tag,
 		parameters:     parameters,
@@ -703,12 +767,42 @@ func (p *ClientParameters) Set(
 
 // Get returns the current parameters. Values read from the current parameters
 // are not deep copies and must be treated read-only.
-func (p *ClientParameters) Get() *ClientParametersSnapshot {
-	return p.snapshot.Load().(*ClientParametersSnapshot)
+//
+// The returned ClientParametersAccessor may be used to read multiple related
+// values atomically and consistently while the current set of values in
+// ClientParameters may change concurrently.
+func (p *ClientParameters) Get() ClientParametersAccessor {
+	return p.snapshot.Load().(*clientParametersSnapshot)
 }
 
-// Tag returns the tag associated with these parameters.
-func (p *ClientParametersSnapshot) Tag() string {
+// GetCustom returns the current parameters while also setting customizations
+// for this instance.
+//
+// Customizations include:
+//
+// - customNetworkLatencyMultiplier, which overrides NetworkLatencyMultiplier
+//   for this instance only.
+//
+func (p *ClientParameters) GetCustom(
+	customNetworkLatencyMultiplier float64) ClientParametersAccessor {
+
+	return &customClientParametersSnapshot{
+		ClientParametersAccessor:       p.Get(),
+		customNetworkLatencyMultiplier: customNetworkLatencyMultiplier,
+	}
+}
+
+// clientParametersSnapshot is an atomic snapshot of the client parameter
+// values. ClientParameters.Get will return a snapshot which may be used to
+// read multiple related values atomically and consistently while the current
+// snapshot in ClientParameters may change concurrently.
+type clientParametersSnapshot struct {
+	getValueLogger func(error)
+	tag            string
+	parameters     map[string]interface{}
+}
+
+func (p *clientParametersSnapshot) Tag() string {
 	return p.tag
 }
 
@@ -724,7 +818,7 @@ func (p *ClientParametersSnapshot) Tag() string {
 // Instead, errors are logged to the getValueLogger and getValue leaves the
 // target unset, which will result in the caller getting and using a zero
 // value of the requested type.
-func (p *ClientParametersSnapshot) getValue(name string, target interface{}) {
+func (p *clientParametersSnapshot) getValue(name string, target interface{}) {
 
 	value, ok := p.parameters[name]
 	if !ok {
@@ -759,53 +853,46 @@ func (p *ClientParametersSnapshot) getValue(name string, target interface{}) {
 	targetValue.Elem().Set(reflect.ValueOf(value))
 }
 
-// String returns a string parameter value.
-func (p *ClientParametersSnapshot) String(name string) string {
+func (p *clientParametersSnapshot) String(name string) string {
 	value := ""
 	p.getValue(name, &value)
 	return value
 }
 
-// Strings returns a []string parameter value.
-func (p *ClientParametersSnapshot) Strings(name string) []string {
+func (p *clientParametersSnapshot) Strings(name string) []string {
 	value := []string{}
 	p.getValue(name, &value)
 	return value
 }
 
 // Int returns an int parameter value.
-func (p *ClientParametersSnapshot) Int(name string) int {
+func (p *clientParametersSnapshot) Int(name string) int {
 	value := int(0)
 	p.getValue(name, &value)
 	return value
 }
 
 // Bool returns a bool parameter value.
-func (p *ClientParametersSnapshot) Bool(name string) bool {
+func (p *clientParametersSnapshot) Bool(name string) bool {
 	value := false
 	p.getValue(name, &value)
 	return value
 }
 
-// Float returns a float64 parameter value.
-func (p *ClientParametersSnapshot) Float(name string) float64 {
+func (p *clientParametersSnapshot) Float(name string) float64 {
 	value := float64(0.0)
 	p.getValue(name, &value)
 	return value
 }
 
-// WeightedCoinFlip returns the result of prng.FlipWeightedCoin using the
-// specified float parameter as the probability input.
-func (p *ClientParametersSnapshot) WeightedCoinFlip(name string) bool {
+func (p *clientParametersSnapshot) WeightedCoinFlip(name string) bool {
 	var value float64
 	p.getValue(name, &value)
 	return prng.FlipWeightedCoin(value)
 }
 
-// Duration returns a time.Duration parameter value. When the duration
-// parameter has the useNetworkLatencyMultiplier flag, the
-// NetworkLatencyMultiplier is applied to the returned value.
-func (p *ClientParametersSnapshot) Duration(name string) time.Duration {
+func (p *clientParametersSnapshot) duration(name string, customNetworkLatencyMultiplier float64) time.Duration {
+
 	value := time.Duration(0)
 	p.getValue(name, &value)
 
@@ -813,7 +900,13 @@ func (p *ClientParametersSnapshot) Duration(name string) time.Duration {
 	if value > 0 && ok && defaultParameter.flags&useNetworkLatencyMultiplier != 0 {
 
 		multiplier := float64(0.0)
-		p.getValue(NetworkLatencyMultiplier, &multiplier)
+
+		if customNetworkLatencyMultiplier != 0.0 {
+			multiplier = customNetworkLatencyMultiplier
+		} else {
+			p.getValue(NetworkLatencyMultiplier, &multiplier)
+		}
+
 		if multiplier > 0.0 {
 			value = time.Duration(float64(value) * multiplier)
 		}
@@ -823,11 +916,11 @@ func (p *ClientParametersSnapshot) Duration(name string) time.Duration {
 	return value
 }
 
-// TunnelProtocols returns a protocol.TunnelProtocols parameter value.
-// If there is a corresponding Probability value, a weighted coin flip
-// will be performed and, depending on the result, the value or the
-// parameter default will be returned.
-func (p *ClientParametersSnapshot) TunnelProtocols(name string) protocol.TunnelProtocols {
+func (p *clientParametersSnapshot) Duration(name string) time.Duration {
+	return p.duration(name, 0.0)
+}
+
+func (p *clientParametersSnapshot) TunnelProtocols(name string) protocol.TunnelProtocols {
 
 	probabilityName := name + "Probability"
 	_, ok := p.parameters[probabilityName]
@@ -852,11 +945,7 @@ func (p *ClientParametersSnapshot) TunnelProtocols(name string) protocol.TunnelP
 	return value
 }
 
-// TLSProfiles returns a protocol.TLSProfiles parameter value.
-// If there is a corresponding Probability value, a weighted coin flip
-// will be performed and, depending on the result, the value or the
-// parameter default will be returned.
-func (p *ClientParametersSnapshot) TLSProfiles(name string) protocol.TLSProfiles {
+func (p *clientParametersSnapshot) TLSProfiles(name string) protocol.TLSProfiles {
 
 	probabilityName := name + "Probability"
 	_, ok := p.parameters[probabilityName]
@@ -881,11 +970,7 @@ func (p *ClientParametersSnapshot) TLSProfiles(name string) protocol.TLSProfiles
 	return value
 }
 
-// QUICVersions returns a protocol.QUICVersions parameter value.
-// If there is a corresponding Probability value, a weighted coin flip
-// will be performed and, depending on the result, the value or the
-// parameter default will be returned.
-func (p *ClientParametersSnapshot) QUICVersions(name string) protocol.QUICVersions {
+func (p *clientParametersSnapshot) QUICVersions(name string) protocol.QUICVersions {
 
 	probabilityName := name + "Probability"
 	_, ok := p.parameters[probabilityName]
@@ -910,30 +995,25 @@ func (p *ClientParametersSnapshot) QUICVersions(name string) protocol.QUICVersio
 	return value
 }
 
-// DownloadURLs returns a DownloadURLs parameter value.
-func (p *ClientParametersSnapshot) DownloadURLs(name string) DownloadURLs {
+func (p *clientParametersSnapshot) DownloadURLs(name string) DownloadURLs {
 	value := DownloadURLs{}
 	p.getValue(name, &value)
 	return value
 }
 
-// RateLimits returns a common.RateLimits parameter value.
-func (p *ClientParametersSnapshot) RateLimits(name string) common.RateLimits {
+func (p *clientParametersSnapshot) RateLimits(name string) common.RateLimits {
 	value := common.RateLimits{}
 	p.getValue(name, &value)
 	return value
 }
 
-// HTTPHeaders returns an http.Header parameter value.
-func (p *ClientParametersSnapshot) HTTPHeaders(name string) http.Header {
+func (p *clientParametersSnapshot) HTTPHeaders(name string) http.Header {
 	value := make(http.Header)
 	p.getValue(name, &value)
 	return value
 }
 
-// CustomTLSProfileNames returns the CustomTLSProfile.Name fields for
-// each profile in the CustomTLSProfiles parameter value.
-func (p *ClientParametersSnapshot) CustomTLSProfileNames() []string {
+func (p *clientParametersSnapshot) CustomTLSProfileNames() []string {
 	value := protocol.CustomTLSProfiles{}
 	p.getValue(CustomTLSProfiles, &value)
 	names := make([]string, len(value))
@@ -943,10 +1023,7 @@ func (p *ClientParametersSnapshot) CustomTLSProfileNames() []string {
 	return names
 }
 
-// CustomTLSProfile returns the CustomTLSProfile fields with the specified
-// Name field if it exists in the CustomTLSProfiles parameter value.
-// Returns nil if not found.
-func (p *ClientParametersSnapshot) CustomTLSProfile(name string) *protocol.CustomTLSProfile {
+func (p *clientParametersSnapshot) CustomTLSProfile(name string) *protocol.CustomTLSProfile {
 	value := protocol.CustomTLSProfiles{}
 	p.getValue(CustomTLSProfiles, &value)
 
@@ -959,3 +1036,15 @@ func (p *ClientParametersSnapshot) CustomTLSProfile(name string) *protocol.Custo
 	}
 	return nil
 }
+
+// customClientParametersSnapshot override the behavior of
+// clientParametersSnapshot to apply customNetworkLatencyMultiplier to
+// Duration parameters.
+type customClientParametersSnapshot struct {
+	ClientParametersAccessor
+	customNetworkLatencyMultiplier float64
+}
+
+func (p *customClientParametersSnapshot) Duration(name string) time.Duration {
+	return p.ClientParametersAccessor.(*clientParametersSnapshot).duration(name, p.customNetworkLatencyMultiplier)
+}

+ 22 - 0
psiphon/common/parameters/clientParameters_test.go

@@ -214,6 +214,28 @@ func TestNetworkLatencyMultiplier(t *testing.T) {
 	}
 }
 
+func TestCustomNetworkLatencyMultiplier(t *testing.T) {
+	p, err := NewClientParameters(nil)
+	if err != nil {
+		t.Fatalf("NewClientParameters failed: %s", err)
+	}
+
+	timeout1 := p.Get().Duration(TunnelConnectTimeout)
+
+	applyParameters := map[string]interface{}{"NetworkLatencyMultiplier": 2.0}
+
+	_, err = p.Set("", false, applyParameters)
+	if err != nil {
+		t.Fatalf("Set failed: %s", err)
+	}
+
+	timeout2 := p.GetCustom(4.0).Duration(TunnelConnectTimeout)
+
+	if 4*timeout1 != timeout2 {
+		t.Fatalf("Unexpected timeouts: 4 * %s != %s", timeout1, timeout2)
+	}
+}
+
 func TestLimitTunnelProtocolProbability(t *testing.T) {
 	p, err := NewClientParameters(nil)
 	if err != nil {

+ 23 - 1
psiphon/common/prng/prng.go

@@ -206,7 +206,6 @@ func (p *PRNG) FlipCoin() bool {
 	return p.rand.Int31n(2) == 1
 }
 
-// FlipWeightedCoin returns the result of a weighted
 // random coin flip. If the weight is 0.5, the outcome
 // is equally likely to be true or false. If the weight
 // is 1.0, the outcome is always true, and if the
@@ -239,6 +238,25 @@ func (p *PRNG) Int63n(n int64) int64 {
 	return p.rand.Int63n(n)
 }
 
+// ExpFloat64Range returns a pseudo-exponentially distributed float64 in the
+// range [min, max] with the specified lambda. Numbers are selected using
+// math/rand.ExpFloat64 and discarding values that exceed max.
+//
+// If max < min or lambda is 0, min is returned.
+func (p *PRNG) ExpFloat64Range(min, max, lambda float64) float64 {
+	if max <= min || lambda == 0.0 {
+		return min
+	}
+	var value float64
+	for {
+		value = min + (rand.ExpFloat64()/lambda)*(max-min)
+		if value <= max {
+			break
+		}
+	}
+	return value
+}
+
 // Intn is equivilent to math/read.Perm.
 func (p *PRNG) Perm(n int) []int {
 	return p.rand.Perm(n)
@@ -331,6 +349,10 @@ func Int63n(n int64) int64 {
 	return p.Int63n(n)
 }
 
+func ExpFloat64Range(min, max, lambda float64) float64 {
+	return p.ExpFloat64Range(min, max, lambda)
+}
+
 func Perm(n int) []int {
 	return p.Perm(n)
 }

+ 66 - 1
psiphon/common/prng/prng_test.go

@@ -25,6 +25,8 @@ import (
 	"fmt"
 	"math"
 	"math/big"
+	"sort"
+	"strings"
 	"testing"
 	"time"
 )
@@ -245,7 +247,7 @@ func TestJitter(t *testing.T) {
 	}
 
 	for _, testCase := range testCases {
-		t.Run(fmt.Sprintf("jitter case: %+v", testCase), func(t *testing.T) {
+		t.Run(fmt.Sprintf("Jitter case: %+v", testCase), func(t *testing.T) {
 
 			p, err := NewPRNG()
 			if err != nil {
@@ -308,6 +310,69 @@ func TestIntn(t *testing.T) {
 	}
 }
 
+func TestExpFloat64Range(t *testing.T) {
+
+	testCases := []struct {
+		min, max, lambda float64
+		factor           int
+	}{
+		{1.0, 3.0, 2.0, 5},
+		{0.0, 1.0, 2.0, 5},
+		{-2.0, -1.0, 2.0, 5},
+	}
+
+	for _, testCase := range testCases {
+		t.Run(fmt.Sprintf("ExpFloat64Range case: %+v", testCase), func(t *testing.T) {
+
+			p, err := NewPRNG()
+			if err != nil {
+				t.Fatalf("NewPRNG failed: %s", err)
+			}
+
+			buckets := make(map[float64]int)
+
+			for i := 0; i < 100000; i++ {
+
+				value := p.ExpFloat64Range(testCase.min, testCase.max, testCase.lambda)
+
+				if value < testCase.min || value > testCase.max {
+					t.Fatalf(
+						"unexpected value: %f [%f, %f]", value, testCase.min, testCase.max)
+				}
+
+				buckets[float64(int(10.0*(value)))/10.0] += 1
+			}
+
+			keys := make([]float64, 0)
+			for k := range buckets {
+				keys = append(keys, k)
+			}
+
+			sort.Float64s(keys)
+
+			strs := make([]string, 0)
+			for _, k := range keys {
+				strs = append(strs, fmt.Sprintf("%0.2f: %d", k, buckets[k]))
+			}
+
+			t.Logf(strings.Join(strs, ","))
+
+			for i := 0; i < len(keys)-1; i++ {
+				if buckets[keys[i]] <= buckets[keys[i+1]] {
+					t.Fatalf("unexpected distribution")
+				}
+			}
+
+			// First bucket should have at least "factor" times more items than last
+			// bucket.
+			if buckets[keys[0]]/buckets[keys[len(keys)-1]] < testCase.factor {
+				t.Fatalf("unexpected distribution")
+			}
+
+		})
+	}
+}
+
 func Disabled_TestRandomStreamLimit(t *testing.T) {
 
 	// This test takes up to ~2 minute to complete, so it's disabled by default.

+ 3 - 3
psiphon/config.go

@@ -807,9 +807,9 @@ func (config *Config) Commit() error {
 	return nil
 }
 
-// GetClientParameters returns a snapshot of the current client parameters.
-func (config *Config) GetClientParametersSnapshot() *parameters.ClientParametersSnapshot {
-	return config.clientParameters.Get()
+// GetClientParameters returns a the current client parameters.
+func (config *Config) GetClientParameters() *parameters.ClientParameters {
+	return config.clientParameters
 }
 
 // SetClientParameters resets Config.clientParameters to the default values,

+ 17 - 17
psiphon/controller.go

@@ -359,7 +359,7 @@ fetcherLoop:
 		// Skip fetch entirely (i.e., send no request at all, even when ETag would save
 		// on response size) when a recent fetch was successful
 
-		stalePeriod := controller.config.GetClientParametersSnapshot().Duration(
+		stalePeriod := controller.config.GetClientParameters().Get().Duration(
 			parameters.FetchRemoteServerListStalePeriod)
 
 		if lastFetchTime != 0 &&
@@ -395,7 +395,7 @@ fetcherLoop:
 
 			NoticeAlert("failed to fetch %s remote server list: %s", name, err)
 
-			retryPeriod := controller.config.GetClientParametersSnapshot().Duration(
+			retryPeriod := controller.config.GetClientParameters().Get().Duration(
 				parameters.FetchRemoteServerListRetryPeriod)
 
 			timer := time.NewTimer(retryPeriod)
@@ -419,7 +419,7 @@ fetcherLoop:
 func (controller *Controller) establishTunnelWatcher() {
 	defer controller.runWaitGroup.Done()
 
-	timeout := controller.config.GetClientParametersSnapshot().Duration(
+	timeout := controller.config.GetClientParameters().Get().Duration(
 		parameters.EstablishTunnelTimeout)
 
 	if timeout > 0 {
@@ -474,7 +474,7 @@ loop:
 		if reported {
 			duration = 24 * time.Hour
 		} else {
-			duration = controller.config.GetClientParametersSnapshot().Duration(
+			duration = controller.config.GetClientParameters().Get().Duration(
 				parameters.PsiphonAPIConnectedRequestRetryPeriod)
 		}
 		timer := time.NewTimer(duration)
@@ -547,7 +547,7 @@ downloadLoop:
 			break downloadLoop
 		}
 
-		stalePeriod := controller.config.GetClientParametersSnapshot().Duration(
+		stalePeriod := controller.config.GetClientParameters().Get().Duration(
 			parameters.FetchUpgradeStalePeriod)
 
 		// Unless handshake is explicitly advertizing a new version, skip
@@ -587,7 +587,7 @@ downloadLoop:
 
 			NoticeAlert("failed to download upgrade: %s", err)
 
-			timeout := controller.config.GetClientParametersSnapshot().Duration(
+			timeout := controller.config.GetClientParameters().Get().Duration(
 				parameters.FetchUpgradeRetryPeriod)
 
 			timer := time.NewTimer(timeout)
@@ -1233,7 +1233,7 @@ func (controller *Controller) launchEstablishing() {
 
 	if !controller.config.DisableTactics {
 
-		timeout := controller.config.GetClientParametersSnapshot().Duration(
+		timeout := controller.config.GetClientParameters().Get().Duration(
 			parameters.TacticsWaitPeriod)
 
 		tacticsDone := make(chan struct{})
@@ -1263,11 +1263,11 @@ func (controller *Controller) launchEstablishing() {
 
 	// Initial- and LimitTunnelProtocols are set once per establishment, for
 	// consistent application of related probabilities (applied by
-	// ClientParametersSnapshot.TunnelProtocols). The
+	// ClientParametersAccessor.TunnelProtocols). The
 	// establishLimitTunnelProtocolsState field must be read-only after this
 	// point, allowing concurrent reads by establishment workers.
 
-	p := controller.config.GetClientParametersSnapshot()
+	p := controller.config.GetClientParameters().Get()
 
 	controller.protocolSelectionConstraints = &protocolSelectionConstraints{
 		useUpstreamProxy:                    controller.config.UseUpstreamProxy(),
@@ -1277,7 +1277,7 @@ func (controller *Controller) launchEstablishing() {
 		replayCandidateCount:                p.Int(parameters.ReplayCandidateCount),
 	}
 
-	workerPoolSize := controller.config.GetClientParametersSnapshot().Int(
+	workerPoolSize := controller.config.GetClientParameters().Get().Int(
 		parameters.ConnectionWorkerPoolSize)
 
 	// When TargetServerEntry is used, override any worker pool size config or
@@ -1484,7 +1484,7 @@ func (controller *Controller) getTactics(done chan struct{}) {
 			// TODO: distinguish network and local errors and abort
 			// on local errors.
 
-			p := controller.config.GetClientParametersSnapshot()
+			p := controller.config.GetClientParameters().Get()
 			timeout := prng.JitterDuration(
 				p.Duration(parameters.TacticsRetryPeriod),
 				p.Float(parameters.TacticsRetryPeriodJitter))
@@ -1572,7 +1572,7 @@ func (controller *Controller) doFetchTactics(
 	// Using controller.establishCtx will cancel FetchTactics
 	// if tunnel establishment completes first.
 
-	timeout := controller.config.GetClientParametersSnapshot().Duration(
+	timeout := controller.config.GetClientParameters().Get().Duration(
 		parameters.TacticsTimeout)
 
 	ctx, cancelFunc := context.WithTimeout(
@@ -1741,7 +1741,7 @@ loop:
 				break loop
 			}
 
-			workTime := controller.config.GetClientParametersSnapshot().Duration(
+			workTime := controller.config.GetClientParameters().Get().Duration(
 				parameters.EstablishTunnelWorkTime)
 
 			if roundStartTime.Add(-roundNetworkWaitDuration).Add(workTime).Before(monotime.Now()) {
@@ -1756,7 +1756,7 @@ loop:
 				// candidate has completed (success or failure) or is still working
 				// and the grace period has elapsed.
 
-				gracePeriod := controller.config.GetClientParametersSnapshot().Duration(
+				gracePeriod := controller.config.GetClientParameters().Get().Duration(
 					parameters.EstablishTunnelServerAffinityGracePeriod)
 
 				if gracePeriod > 0 {
@@ -1786,7 +1786,7 @@ loop:
 		// in typical conditions (it isn't strictly necessary to wait for this, there will
 		// be more rounds if required).
 
-		p := controller.config.GetClientParametersSnapshot()
+		p := controller.config.GetClientParameters().Get()
 		timeout := prng.JitterDuration(
 			p.Duration(parameters.EstablishTunnelPausePeriod),
 			p.Float(parameters.EstablishTunnelPausePeriodJitter))
@@ -1837,7 +1837,7 @@ loop:
 		// intensive. In this case, a StaggerConnectionWorkersMilliseconds
 		// delay may still be incurred.
 
-		limitIntensiveConnectionWorkers := controller.config.GetClientParametersSnapshot().Int(
+		limitIntensiveConnectionWorkers := controller.config.GetClientParameters().Get().Int(
 			parameters.LimitIntensiveConnectionWorkers)
 
 		controller.concurrentEstablishTunnelsMutex.Lock()
@@ -1945,7 +1945,7 @@ loop:
 		// The stagger is applied when establishConnectTunnelCount > 0 -- that
 		// is, for all but the first dial.
 
-		p := controller.config.GetClientParametersSnapshot()
+		p := controller.config.GetClientParameters().Get()
 		staggerPeriod := p.Duration(parameters.StaggerConnectionWorkersPeriod)
 		staggerJitter := p.Float(parameters.StaggerConnectionWorkersJitter)
 		p = nil

+ 7 - 9
psiphon/dataStore.go

@@ -484,7 +484,7 @@ func newTargetServerEntryIterator(config *Config, isTactics bool) (bool, *Server
 			return false, nil, common.ContextError(errors.New("TargetServerEntry does not support EgressRegion"))
 		}
 
-		limitTunnelProtocols := config.GetClientParametersSnapshot().TunnelProtocols(parameters.LimitTunnelProtocols)
+		limitTunnelProtocols := config.GetClientParameters().Get().TunnelProtocols(parameters.LimitTunnelProtocols)
 		if len(limitTunnelProtocols) > 0 {
 			// At the ServerEntryIterator level, only limitTunnelProtocols is applied;
 			// excludeIntensive is handled higher up.
@@ -589,12 +589,10 @@ func (iterator *ServerEntryIterator) reset(isInitialRound bool) error {
 		//
 		// TODO: move only up to parameters.ReplayCandidateCount to front?
 
-		if (isInitialRound ||
-			iterator.config.GetClientParametersSnapshot().WeightedCoinFlip(
-				parameters.ReplayLaterRoundMoveToFrontProbability)) &&
+		p := iterator.config.GetClientParameters().Get()
 
-			iterator.config.GetClientParametersSnapshot().Int(
-				parameters.ReplayCandidateCount) != 0 {
+		if (isInitialRound || p.WeightedCoinFlip(parameters.ReplayLaterRoundMoveToFrontProbability)) &&
+			p.Int(parameters.ReplayCandidateCount) != 0 {
 
 			networkID := []byte(iterator.config.GetNetworkID())
 
@@ -837,7 +835,7 @@ func PruneServerEntry(config *Config, serverEntryTag string) {
 
 func pruneServerEntry(config *Config, serverEntryTag string) error {
 
-	minimumAgeForPruning := config.GetClientParametersSnapshot().Duration(
+	minimumAgeForPruning := config.GetClientParameters().Get().Duration(
 		parameters.ServerEntryMinimumAgeForPruning)
 
 	return datastoreUpdate(func(tx *datastoreTx) error {
@@ -1243,7 +1241,7 @@ func StorePersistentStat(config *Config, statType string, stat []byte) error {
 		return common.ContextError(fmt.Errorf("invalid persistent stat type: %s", statType))
 	}
 
-	maxStoreRecords := config.GetClientParametersSnapshot().Int(
+	maxStoreRecords := config.GetClientParameters().Get().Int(
 		parameters.PersistentStatsMaxStoreRecords)
 
 	err := datastoreUpdate(func(tx *datastoreTx) error {
@@ -1319,7 +1317,7 @@ func TakeOutUnreportedPersistentStats(config *Config) (map[string][][]byte, erro
 
 	stats := make(map[string][][]byte)
 
-	maxSendBytes := config.GetClientParametersSnapshot().Int(
+	maxSendBytes := config.GetClientParameters().Get().Int(
 		parameters.PersistentStatsMaxSendBytes)
 
 	err := datastoreUpdate(func(tx *datastoreTx) error {

+ 45 - 24
psiphon/dialParameters.go

@@ -66,6 +66,8 @@ type DialParameters struct {
 	LastUsedTimestamp       time.Time
 	LastUsedConfigStateHash []byte
 
+	CustomNetworkLatencyMultiplier float64
+
 	TunnelProtocol string
 
 	DirectDialAddress              string
@@ -143,7 +145,7 @@ func MakeDialParameters(
 
 	networkID := config.GetNetworkID()
 
-	p := config.GetClientParametersSnapshot()
+	p := config.GetClientParameters().Get()
 
 	ttl := p.Duration(parameters.ReplayDialParametersTTL)
 	replaySSH := p.Bool(parameters.ReplaySSH)
@@ -270,6 +272,24 @@ func MakeDialParameters(
 	// replaying, existing parameters are retaing, subject to the replay-X
 	// tactics flags.
 
+	// Select a random, custom network latency multiplier. This allows clients to
+	// explore and discover timeout values appropriate for the current network.
+	// The selection applies per tunnel, to avoid delaying all establishment
+	// candidates due to excessive timeouts. The random selection is bounded by a
+	// min/max set in tactics and an exponential distribution is used so as to
+	// heavily favor values closed to the min, which should be set to the
+	// traditional NetworkLatencyMultiplier value.
+	//
+	// Not all existing, persisted DialParameters will have a
+	// CustomNetworkLatencyMultiplier value. Its zero value will cause the
+	// standard NetworkLatencyMultiplier to be used instead.
+	if !isReplay {
+		dialParams.CustomNetworkLatencyMultiplier = prng.ExpFloat64Range(
+			p.Float(parameters.CustomNetworkLatencyMultiplierMin),
+			p.Float(parameters.CustomNetworkLatencyMultiplierMax),
+			p.Float(parameters.CustomNetworkLatencyMultiplierLambda))
+	}
+
 	if !isReplay && !isExchanged {
 
 		// TODO: should there be a pre-check of selectProtocol before incurring
@@ -584,23 +604,24 @@ func MakeDialParameters(
 	if protocol.TunnelProtocolUsesMeek(dialParams.TunnelProtocol) {
 
 		dialParams.meekConfig = &MeekConfig{
-			DiagnosticID:                  serverEntry.GetDiagnosticID(),
-			ClientParameters:              config.clientParameters,
-			DialAddress:                   dialParams.MeekDialAddress,
-			UseQUIC:                       protocol.TunnelProtocolUsesFrontedMeekQUIC(dialParams.TunnelProtocol),
-			QUICVersion:                   dialParams.QUICVersion,
-			UseHTTPS:                      protocol.TunnelProtocolUsesMeekHTTPS(dialParams.TunnelProtocol),
-			TLSProfile:                    dialParams.TLSProfile,
-			NoDefaultTLSSessionID:         dialParams.NoDefaultTLSSessionID,
-			RandomizedTLSProfileSeed:      dialParams.RandomizedTLSProfileSeed,
-			UseObfuscatedSessionTickets:   dialParams.TunnelProtocol == protocol.TUNNEL_PROTOCOL_UNFRONTED_MEEK_SESSION_TICKET,
-			SNIServerName:                 dialParams.MeekSNIServerName,
-			HostHeader:                    dialParams.MeekHostHeader,
-			TransformedHostName:           dialParams.MeekTransformedHostName,
-			ClientTunnelProtocol:          dialParams.TunnelProtocol,
-			MeekCookieEncryptionPublicKey: serverEntry.MeekCookieEncryptionPublicKey,
-			MeekObfuscatedKey:             serverEntry.MeekObfuscatedKey,
-			MeekObfuscatorPaddingSeed:     dialParams.MeekObfuscatorPaddingSeed,
+			DiagnosticID:                   serverEntry.GetDiagnosticID(),
+			ClientParameters:               config.clientParameters,
+			DialAddress:                    dialParams.MeekDialAddress,
+			UseQUIC:                        protocol.TunnelProtocolUsesFrontedMeekQUIC(dialParams.TunnelProtocol),
+			QUICVersion:                    dialParams.QUICVersion,
+			UseHTTPS:                       protocol.TunnelProtocolUsesMeekHTTPS(dialParams.TunnelProtocol),
+			TLSProfile:                     dialParams.TLSProfile,
+			NoDefaultTLSSessionID:          dialParams.NoDefaultTLSSessionID,
+			RandomizedTLSProfileSeed:       dialParams.RandomizedTLSProfileSeed,
+			UseObfuscatedSessionTickets:    dialParams.TunnelProtocol == protocol.TUNNEL_PROTOCOL_UNFRONTED_MEEK_SESSION_TICKET,
+			SNIServerName:                  dialParams.MeekSNIServerName,
+			HostHeader:                     dialParams.MeekHostHeader,
+			TransformedHostName:            dialParams.MeekTransformedHostName,
+			ClientTunnelProtocol:           dialParams.TunnelProtocol,
+			MeekCookieEncryptionPublicKey:  serverEntry.MeekCookieEncryptionPublicKey,
+			MeekObfuscatedKey:              serverEntry.MeekObfuscatedKey,
+			MeekObfuscatorPaddingSeed:      dialParams.MeekObfuscatorPaddingSeed,
+			CustomNetworkLatencyMultiplier: dialParams.CustomNetworkLatencyMultiplier,
 		}
 
 		// Use an asynchronous callback to record the resolved IP address when
@@ -657,7 +678,7 @@ func (dialParams *DialParameters) Failed(config *Config) {
 	// to, e.g., temporary network disruptions or server load limiting.
 
 	if dialParams.IsReplay &&
-		!config.GetClientParametersSnapshot().WeightedCoinFlip(
+		!config.GetClientParameters().Get().WeightedCoinFlip(
 			parameters.ReplayRetainFailedProbability) {
 
 		NoticeInfo("Delete dial parameters for %s", dialParams.ServerEntry.GetDiagnosticID())
@@ -746,7 +767,7 @@ func (dialParams *ExchangedDialParameters) Validate(serverEntry *protocol.Server
 // then later fully initialized by MakeDialParameters.
 func (dialParams *ExchangedDialParameters) MakeDialParameters(
 	config *Config,
-	p *parameters.ClientParametersSnapshot,
+	p parameters.ClientParametersAccessor,
 	serverEntry *protocol.ServerEntry) *DialParameters {
 
 	return &DialParameters{
@@ -759,7 +780,7 @@ func (dialParams *ExchangedDialParameters) MakeDialParameters(
 
 func getConfigStateHash(
 	config *Config,
-	p *parameters.ClientParametersSnapshot,
+	p parameters.ClientParametersAccessor,
 	serverEntry *protocol.ServerEntry) []byte {
 
 	// The config state hash should reflect config, tactics, and server entry
@@ -835,7 +856,7 @@ func selectFrontingParameters(serverEntry *protocol.ServerEntry) (string, string
 	return frontingDialHost, frontingHost, nil
 }
 
-func selectQUICVersion(allowObfuscatedQUIC bool, p *parameters.ClientParametersSnapshot) string {
+func selectQUICVersion(allowObfuscatedQUIC bool, p parameters.ClientParametersAccessor) string {
 
 	limitQUICVersions := p.QUICVersions(parameters.LimitQUICVersions)
 
@@ -867,7 +888,7 @@ func selectQUICVersion(allowObfuscatedQUIC bool, p *parameters.ClientParametersS
 
 // selectUserAgentIfUnset selects a User-Agent header if one is not set.
 func selectUserAgentIfUnset(
-	p *parameters.ClientParametersSnapshot, headers http.Header) (bool, string) {
+	p parameters.ClientParametersAccessor, headers http.Header) (bool, string) {
 
 	if _, ok := headers["User-Agent"]; !ok {
 
@@ -884,7 +905,7 @@ func selectUserAgentIfUnset(
 
 func makeDialCustomHeaders(
 	config *Config,
-	p *parameters.ClientParametersSnapshot) http.Header {
+	p parameters.ClientParametersAccessor) http.Header {
 
 	dialCustomHeaders := make(http.Header)
 	if config.CustomHeaders != nil {

+ 1 - 1
psiphon/exchange.go

@@ -254,7 +254,7 @@ func importExchangePayload(config *Config, encodedPayload string) error {
 		if err == nil {
 			dialParams := payload.ExchangedDialParameters.MakeDialParameters(
 				config,
-				config.GetClientParametersSnapshot(),
+				config.GetClientParameters().Get(),
 				serverEntry)
 
 			err = SetDialParameters(

+ 1 - 1
psiphon/httpProxy.go

@@ -117,7 +117,7 @@ func NewHttpProxy(
 		return tunneler.DirectDial(addr)
 	}
 
-	p := config.GetClientParametersSnapshot()
+	p := config.GetClientParameters().Get()
 	responseHeaderTimeout := p.Duration(parameters.HTTPProxyOriginServerTimeout)
 	maxIdleConnsPerHost := p.Int(parameters.HTTPProxyMaxIdleConnectionsPerHost)
 	p = nil

+ 46 - 38
psiphon/meekConn.go

@@ -133,6 +133,10 @@ type MeekConfig struct {
 	// incuding buffers are allocated.
 	RoundTripperOnly bool
 
+	// CustomNetworkLatencyMultiplier specifies a custom network latency
+	// multiplier to apply to client parameters used by this meek connection.
+	CustomNetworkLatencyMultiplier float64
+
 	// The following values are used to create the obfuscated meek cookie.
 
 	MeekCookieEncryptionPublicKey string
@@ -152,21 +156,22 @@ type MeekConfig struct {
 // MeekConn also operates in unfronted mode, in which plain HTTP connections are made without routing
 // through a CDN.
 type MeekConn struct {
-	clientParameters          *parameters.ClientParameters
-	isQUIC                    bool
-	url                       *url.URL
-	additionalHeaders         http.Header
-	cookie                    *http.Cookie
-	cookieSize                int
-	limitRequestPayloadLength int
-	redialTLSProbability      float64
-	cachedTLSDialer           *cachedTLSDialer
-	transport                 transporter
-	mutex                     sync.Mutex
-	isClosed                  bool
-	runCtx                    context.Context
-	stopRunning               context.CancelFunc
-	relayWaitGroup            *sync.WaitGroup
+	clientParameters               *parameters.ClientParameters
+	customNetworkLatencyMultiplier float64
+	isQUIC                         bool
+	url                            *url.URL
+	additionalHeaders              http.Header
+	cookie                         *http.Cookie
+	cookieSize                     int
+	limitRequestPayloadLength      int
+	redialTLSProbability           float64
+	cachedTLSDialer                *cachedTLSDialer
+	transport                      transporter
+	mutex                          sync.Mutex
+	isClosed                       bool
+	runCtx                         context.Context
+	stopRunning                    context.CancelFunc
+	relayWaitGroup                 *sync.WaitGroup
 
 	// For round tripper mode
 	roundTripperOnly              bool
@@ -186,6 +191,10 @@ type MeekConn struct {
 	fullSendBuffer          chan *bytes.Buffer
 }
 
+func (conn *MeekConn) getCustomClientParameters() parameters.ClientParametersAccessor {
+	return conn.clientParameters.GetCustom(conn.customNetworkLatencyMultiplier)
+}
+
 // transporter is implemented by both http.Transport and upstreamproxy.ProxyAuthTransport.
 type transporter interface {
 	CloseIdleConnections()
@@ -302,7 +311,7 @@ func DialMeek(
 			RandomizedTLSProfileSeed:      meekConfig.RandomizedTLSProfileSeed,
 			TrustedCACertificatesFilename: dialConfig.TrustedCACertificatesFilename,
 		}
-		tlsConfig.EnableClientSessionCache(meekConfig.ClientParameters)
+		tlsConfig.EnableClientSessionCache()
 
 		if meekConfig.UseObfuscatedSessionTickets {
 			tlsConfig.ObfuscatedSessionTicketKey = meekConfig.MeekObfuscatedKey
@@ -465,17 +474,18 @@ func DialMeek(
 	// Write() calls and relay() are synchronized in a similar way, using a single
 	// sendBuffer.
 	meek = &MeekConn{
-		clientParameters:  meekConfig.ClientParameters,
-		isQUIC:            isQUIC,
-		url:               url,
-		additionalHeaders: additionalHeaders,
-		cachedTLSDialer:   cachedTLSDialer,
-		transport:         transport,
-		isClosed:          false,
-		runCtx:            runCtx,
-		stopRunning:       stopRunning,
-		relayWaitGroup:    new(sync.WaitGroup),
-		roundTripperOnly:  meekConfig.RoundTripperOnly,
+		clientParameters:               meekConfig.ClientParameters,
+		customNetworkLatencyMultiplier: meekConfig.CustomNetworkLatencyMultiplier,
+		isQUIC:                         isQUIC,
+		url:                            url,
+		additionalHeaders:              additionalHeaders,
+		cachedTLSDialer:                cachedTLSDialer,
+		transport:                      transport,
+		isClosed:                       false,
+		runCtx:                         runCtx,
+		stopRunning:                    stopRunning,
+		relayWaitGroup:                 new(sync.WaitGroup),
+		roundTripperOnly:               meekConfig.RoundTripperOnly,
 	}
 
 	// stopRunning and cachedTLSDialer will now be closed in meek.Close()
@@ -488,7 +498,7 @@ func DialMeek(
 
 		cookie, limitRequestPayloadLength, redialTLSProbability, err :=
 			makeMeekObfuscationValues(
-				meek.clientParameters,
+				meek.getCustomClientParameters(),
 				meekConfig.MeekCookieEncryptionPublicKey,
 				meekConfig.MeekObfuscatedKey,
 				meekConfig.MeekObfuscatorPaddingSeed,
@@ -503,7 +513,7 @@ func DialMeek(
 		meek.limitRequestPayloadLength = limitRequestPayloadLength
 		meek.redialTLSProbability = redialTLSProbability
 
-		p := meekConfig.ClientParameters.Get()
+		p := meek.getCustomClientParameters()
 		if p.Bool(parameters.MeekLimitBufferSizes) {
 			meek.fullReceiveBufferLength = p.Int(parameters.MeekLimitedFullReceiveBufferLength)
 			meek.readPayloadChunkLength = p.Int(parameters.MeekLimitedReadPayloadChunkLength)
@@ -661,7 +671,7 @@ func (meek *MeekConn) RoundTrip(
 	}
 
 	cookie, _, _, err := makeMeekObfuscationValues(
-		meek.clientParameters,
+		meek.getCustomClientParameters(),
 		meek.meekCookieEncryptionPublicKey,
 		meek.meekObfuscatedKey,
 		meek.meekObfuscatorPaddingSeed,
@@ -830,7 +840,7 @@ func (meek *MeekConn) relay() {
 	// (using goroutines) since Close() will wait on this WaitGroup.
 	defer meek.relayWaitGroup.Done()
 
-	p := meek.clientParameters.Get()
+	p := meek.getCustomClientParameters()
 	interval := prng.JitterDuration(
 		p.Duration(parameters.MeekMinPollInterval),
 		p.Float(parameters.MeekMinPollIntervalJitter))
@@ -901,7 +911,7 @@ func (meek *MeekConn) relay() {
 		// flips are used to avoid trivial, static traffic
 		// timing patterns.
 
-		p := meek.clientParameters.Get()
+		p := meek.getCustomClientParameters()
 
 		if receivedPayloadSize > 0 || sendPayloadSize > 0 {
 
@@ -1005,7 +1015,7 @@ func (meek *MeekConn) newRequest(
 		// - round trip will abort if it exceeds timeout
 		requestCtx, cancelFunc = context.WithTimeout(
 			meek.runCtx,
-			meek.clientParameters.Get().Duration(parameters.MeekRoundTripTimeout))
+			meek.getCustomClientParameters().Duration(parameters.MeekRoundTripTimeout))
 	}
 
 	// Ensure dials are made within the current request context.
@@ -1087,7 +1097,7 @@ func (meek *MeekConn) relayRoundTrip(sendBuffer *bytes.Buffer) (int64, error) {
 
 	retries := uint(0)
 
-	p := meek.clientParameters.Get()
+	p := meek.getCustomClientParameters()
 	retryDeadline := monotime.Now().Add(p.Duration(parameters.MeekRoundTripRetryDeadline))
 	retryDelay := p.Duration(parameters.MeekRoundTripRetryMinDelay)
 	retryMaxDelay := p.Duration(parameters.MeekRoundTripRetryMaxDelay)
@@ -1333,10 +1343,10 @@ func (meek *MeekConn) readPayload(
 // Obsolete meek cookie fields used by the legacy server stack are no longer
 // sent. These include ServerAddress and SessionID.
 //
-// The request paylod limit and TLS redial probability apply only to relay
+// The request payload limit and TLS redial probability apply only to relay
 // mode and are selected once and used for the duration of a meek connction.
 func makeMeekObfuscationValues(
-	clientParameters *parameters.ClientParameters,
+	p parameters.ClientParametersAccessor,
 	meekCookieEncryptionPublicKey string,
 	meekObfuscatedKey string,
 	meekObfuscatorPaddingPRNGSeed *prng.Seed,
@@ -1381,8 +1391,6 @@ func makeMeekObfuscationValues(
 	copy(encryptedCookie[0:32], ephemeralPublicKey[0:32])
 	copy(encryptedCookie[32:], box)
 
-	p := clientParameters.Get()
-
 	maxPadding := p.Int(parameters.MeekCookieMaxPadding)
 
 	// Obfuscate the encrypted data

+ 1 - 1
psiphon/net.go

@@ -315,7 +315,7 @@ func MakeUntunneledHTTPClient(
 		SkipVerify:                    skipVerify,
 		TrustedCACertificatesFilename: untunneledDialConfig.TrustedCACertificatesFilename,
 	}
-	tlsConfig.EnableClientSessionCache(config.clientParameters)
+	tlsConfig.EnableClientSessionCache()
 
 	tlsDialer := NewCustomTLSDialer(tlsConfig)
 

+ 9 - 0
psiphon/notice.go

@@ -432,6 +432,7 @@ func noticeWithDialParameters(noticeType string, dialParams *DialParameters) {
 		"region", dialParams.ServerEntry.Region,
 		"protocol", dialParams.TunnelProtocol,
 		"isReplay", dialParams.IsReplay,
+		"candidateNumber", dialParams.CandidateNumber,
 	}
 
 	if GetEmitNetworkParameters() {
@@ -491,6 +492,14 @@ func noticeWithDialParameters(noticeType string, dialParams *DialParameters) {
 			args = append(args, "QUICDialSNIAddress", dialParams.QUICDialSNIAddress)
 		}
 
+		if dialParams.DialDuration > 0 {
+			args = append(args, "dialDuration", dialParams.DialDuration)
+		}
+
+		if dialParams.CustomNetworkLatencyMultiplier != 0.0 {
+			args = append(args, "networkLatencyMultiplier", dialParams.CustomNetworkLatencyMultiplier)
+		}
+
 		if dialParams.DialConnMetrics != nil {
 			metrics := dialParams.DialConnMetrics.GetMetrics()
 			for name, value := range metrics {

+ 2 - 2
psiphon/remoteServerList.go

@@ -53,7 +53,7 @@ func FetchCommonRemoteServerList(
 
 	NoticeInfo("fetching common remote server list")
 
-	p := config.GetClientParametersSnapshot()
+	p := config.GetClientParameters().Get()
 	publicKey := p.String(parameters.RemoteServerListSignaturePublicKey)
 	urls := p.DownloadURLs(parameters.RemoteServerListURLs)
 	downloadTimeout := p.Duration(parameters.FetchRemoteServerListTimeout)
@@ -136,7 +136,7 @@ func FetchObfuscatedServerLists(
 
 	NoticeInfo("fetching obfuscated remote server lists")
 
-	p := config.GetClientParametersSnapshot()
+	p := config.GetClientParameters().Get()
 	publicKey := p.String(parameters.RemoteServerListSignaturePublicKey)
 	urls := p.DownloadURLs(parameters.ObfuscatedServerListRootURLs)
 	downloadTimeout := p.Duration(parameters.FetchRemoteServerListTimeout)

+ 15 - 4
psiphon/server/api.go

@@ -688,10 +688,11 @@ const (
 	requestParamArray                                         = 1 << 2
 	requestParamJSON                                          = 1 << 3
 	requestParamLogStringAsInt                                = 1 << 4
-	requestParamLogStringLengthAsInt                          = 1 << 5
-	requestParamLogFlagAsBool                                 = 1 << 6
-	requestParamLogOnlyForFrontedMeek                         = 1 << 7
-	requestParamNotLoggedForUnfrontedMeekNonTransformedHeader = 1 << 8
+	requestParamLogStringAsFloat                              = 1 << 5
+	requestParamLogStringLengthAsInt                          = 1 << 6
+	requestParamLogFlagAsBool                                 = 1 << 7
+	requestParamLogOnlyForFrontedMeek                         = 1 << 8
+	requestParamNotLoggedForUnfrontedMeekNonTransformedHeader = 1 << 9
 )
 
 // baseRequestParams is the list of required and optional
@@ -742,6 +743,7 @@ var baseRequestParams = []requestParamSpec{
 	{"upstream_ossh_padding", isIntString, requestParamOptional | requestParamLogStringAsInt},
 	{"meek_cookie_size", isIntString, requestParamOptional | requestParamLogStringAsInt},
 	{"meek_limit_request", isIntString, requestParamOptional | requestParamLogStringAsInt},
+	{"network_latency_multiplier", isFloatString, requestParamOptional | requestParamLogStringAsFloat},
 }
 
 func validateRequestParams(
@@ -961,6 +963,10 @@ func getRequestLogFields(
 					intValue, _ := strconv.Atoi(strValue)
 					logFields[expectedParam.name] = intValue
 
+				} else if expectedParam.flags&requestParamLogStringAsFloat != 0 {
+					floatValue, _ := strconv.ParseFloat(strValue, 64)
+					logFields[expectedParam.name] = floatValue
+
 				} else if expectedParam.flags&requestParamLogStringLengthAsInt != 0 {
 					logFields[expectedParam.name] = len(strValue)
 
@@ -1202,6 +1208,11 @@ func isIntString(_ *Config, value string) bool {
 	return err == nil
 }
 
+func isFloatString(_ *Config, value string) bool {
+	_, err := strconv.ParseFloat(value, 64)
+	return err == nil
+}
+
 func isClientPlatform(_ *Config, value string) bool {
 	return -1 == strings.IndexFunc(value, func(c rune) bool {
 		// Note: stricter than psi_web's Python string.whitespace

+ 1 - 0
psiphon/server/server_test.go

@@ -1139,6 +1139,7 @@ func checkExpectedLogFields(runConfig *runServerConfig, fields map[string]interf
 		"is_replay",
 		"dial_duration",
 		"candidate_number",
+		"network_latency_multiplier",
 	} {
 		if fields[name] == nil || fmt.Sprintf("%s", fields[name]) == "" {
 			return fmt.Errorf("missing expected field '%s'", name)

+ 10 - 5
psiphon/serverApi.go

@@ -97,7 +97,7 @@ func NewServerContext(tunnel *Tunnel) (*ServerContext, error) {
 		paddingPRNG:        prng.NewPRNGWithSeed(tunnel.dialParams.APIRequestPaddingSeed),
 	}
 
-	ignoreRegexps := tunnel.config.GetClientParametersSnapshot().Bool(
+	ignoreRegexps := tunnel.config.GetClientParameters().Get().Bool(
 		parameters.IgnoreHandshakeStatsRegexps)
 
 	err := serverContext.doHandshakeRequest(ignoreRegexps)
@@ -604,7 +604,7 @@ func confirmStatusRequestPayload(payloadInfo *statusRequestPayloadInfo) {
 func RecordRemoteServerListStat(
 	config *Config, url, etag string) error {
 
-	if !config.GetClientParametersSnapshot().WeightedCoinFlip(
+	if !config.GetClientParameters().Get().WeightedCoinFlip(
 		parameters.RecordRemoteServerListPersistentStatsProbability) {
 		return nil
 	}
@@ -639,7 +639,7 @@ func RecordRemoteServerListStat(
 func RecordFailedTunnelStat(
 	config *Config, dialParams *DialParameters, tunnelErr error) error {
 
-	if !config.GetClientParametersSnapshot().WeightedCoinFlip(
+	if !config.GetClientParameters().Get().WeightedCoinFlip(
 		parameters.RecordFailedTunnelPersistentStatsProbability) {
 		return nil
 	}
@@ -749,7 +749,7 @@ func (serverContext *ServerContext) getBaseAPIParameters() common.APIParameters
 	// fingerprints. The "pad_response" field instructs the server to pad its
 	// response accordingly.
 
-	p := serverContext.tunnel.config.GetClientParametersSnapshot()
+	p := serverContext.tunnel.config.GetClientParameters().Get()
 	minUpstreamPadding := p.Int(parameters.APIRequestUpstreamPaddingMinBytes)
 	maxUpstreamPadding := p.Int(parameters.APIRequestUpstreamPaddingMaxBytes)
 	minDownstreamPadding := p.Int(parameters.APIRequestDownstreamPaddingMinBytes)
@@ -861,7 +861,7 @@ func getBaseAPIParameters(
 	}
 
 	params[tactics.APPLIED_TACTICS_TAG_PARAMETER_NAME] =
-		config.GetClientParametersSnapshot().Tag()
+		config.GetClientParameters().Get().Tag()
 
 	if dialParams.DialPortNumber != "" {
 		params["dial_port_number"] = dialParams.DialPortNumber
@@ -890,6 +890,11 @@ func getBaseAPIParameters(
 
 	params["candidate_number"] = strconv.Itoa(dialParams.CandidateNumber)
 
+	if dialParams.CustomNetworkLatencyMultiplier != 0.0 {
+		params["network_latency_multiplier"] =
+			fmt.Sprintf("%f", dialParams.CustomNetworkLatencyMultiplier)
+	}
+
 	if dialParams.DialConnMetrics != nil {
 		metrics := dialParams.DialConnMetrics.GetMetrics()
 		for name, value := range metrics {

+ 3 - 5
psiphon/tlsDialer.go

@@ -141,9 +141,7 @@ type CustomTLSConfig struct {
 // EnableClientSessionCache initializes a cache to use to persist session
 // tickets, enabling TLS session resumability across multiple
 // CustomTLSDial calls or dialers using the same CustomTLSConfig.
-func (config *CustomTLSConfig) EnableClientSessionCache(
-	clientParameters *parameters.ClientParameters) {
-
+func (config *CustomTLSConfig) EnableClientSessionCache() {
 	if config.clientSessionCache == nil {
 		config.clientSessionCache = utls.NewLRUClientSessionCache(0)
 	}
@@ -151,7 +149,7 @@ func (config *CustomTLSConfig) EnableClientSessionCache(
 
 // SelectTLSProfile picks a TLS profile at random from the available candidates.
 func SelectTLSProfile(
-	p *parameters.ClientParametersSnapshot) string {
+	p parameters.ClientParametersAccessor) string {
 
 	// Two TLS profile lists are constructed, subject to limit constraints:
 	// stock, fixed parrots (non-randomized SupportedTLSProfiles) and custom
@@ -208,7 +206,7 @@ func SelectTLSProfile(
 }
 
 func getUTLSClientHelloID(
-	p *parameters.ClientParametersSnapshot,
+	p parameters.ClientParametersAccessor,
 	tlsProfile string) (utls.ClientHelloID, *utls.ClientHelloSpec, error) {
 
 	switch tlsProfile {

+ 30 - 17
psiphon/tunnel.go

@@ -102,6 +102,19 @@ type Tunnel struct {
 	establishedTime            monotime.Time
 }
 
+// getCustomClientParameters helpers wrap the verbose function call chain
+// required to get a current snapshot of the ClientParameters customized with
+// the dial parameters associated with a tunnel.
+
+func (tunnel *Tunnel) getCustomClientParameters() parameters.ClientParametersAccessor {
+	return getCustomClientParameters(tunnel.config, tunnel.dialParams)
+}
+
+func getCustomClientParameters(
+	config *Config, dialParams *DialParameters) parameters.ClientParametersAccessor {
+	return config.GetClientParameters().GetCustom(dialParams.CustomNetworkLatencyMultiplier)
+}
+
 // ConnectTunnel first makes a network transport connection to the
 // Psiphon server and then establishes an SSH client session on top of
 // that transport. The SSH server is authenticated using the public
@@ -186,7 +199,7 @@ func (tunnel *Tunnel) Activate(
 		// request. At this point, there is no operateTunnel monitor that will detect
 		// this condition with SSH keep alives.
 
-		timeout := tunnel.config.GetClientParametersSnapshot().Duration(
+		timeout := tunnel.getCustomClientParameters().Duration(
 			parameters.PsiphonAPIRequestTimeout)
 
 		if timeout > 0 {
@@ -293,7 +306,7 @@ func (tunnel *Tunnel) Close(isDiscarded bool) {
 		// tunnel is closed, which will interrupt any slow final status request.
 
 		if isActivated {
-			timeout := tunnel.config.GetClientParametersSnapshot().Duration(
+			timeout := tunnel.getCustomClientParameters().Duration(
 				parameters.TunnelOperateShutdownTimeout)
 			afterFunc := time.AfterFunc(
 				timeout,
@@ -372,7 +385,7 @@ func (tunnel *Tunnel) Dial(
 
 	resultChannel := make(chan *tunnelDialResult, 1)
 
-	timeout := tunnel.config.GetClientParametersSnapshot().Duration(
+	timeout := tunnel.getCustomClientParameters().Duration(
 		parameters.TunnelPortForwardDialTimeout)
 
 	afterFunc := time.AfterFunc(
@@ -530,7 +543,7 @@ func dialTunnel(
 		return nil, common.ContextError(err)
 	}
 
-	p := config.GetClientParametersSnapshot()
+	p := getCustomClientParameters(config, dialParams)
 	timeout := p.Duration(parameters.TunnelConnectTimeout)
 	rateLimits := p.RateLimits(parameters.TunnelRateLimits)
 	obfuscatedSSHMinPadding := p.Int(parameters.ObfuscatedSSHMinPadding)
@@ -996,8 +1009,6 @@ func performLivenessTest(
 func (tunnel *Tunnel) operateTunnel(tunnelOwner TunnelOwner) {
 	defer tunnel.operateWaitGroup.Done()
 
-	clientParameters := tunnel.config.clientParameters
-
 	lastBytesReceivedTime := monotime.Now()
 
 	lastTotalBytesTransferedTime := monotime.Now()
@@ -1012,7 +1023,7 @@ func (tunnel *Tunnel) operateTunnel(tunnelOwner TunnelOwner) {
 	// from a range, to make the resulting traffic less fingerprintable,
 	// Note: not using Tickers since these are not fixed time periods.
 	nextStatusRequestPeriod := func() time.Duration {
-		p := clientParameters.Get()
+		p := tunnel.getCustomClientParameters()
 		return prng.Period(
 			p.Duration(parameters.PsiphonAPIStatusRequestPeriodMin),
 			p.Duration(parameters.PsiphonAPIStatusRequestPeriodMax))
@@ -1026,7 +1037,7 @@ func (tunnel *Tunnel) operateTunnel(tunnelOwner TunnelOwner) {
 	unreported := CountUnreportedPersistentStats()
 	if unreported > 0 {
 		NoticeInfo("Unreported persistent stats: %d", unreported)
-		p := clientParameters.Get()
+		p := tunnel.getCustomClientParameters()
 		statsTimer.Reset(
 			prng.Period(
 				p.Duration(parameters.PsiphonAPIStatusRequestShortPeriodMin),
@@ -1034,7 +1045,7 @@ func (tunnel *Tunnel) operateTunnel(tunnelOwner TunnelOwner) {
 	}
 
 	nextSshKeepAlivePeriod := func() time.Duration {
-		p := clientParameters.Get()
+		p := tunnel.getCustomClientParameters()
 		return prng.Period(
 			p.Duration(parameters.SSHKeepAlivePeriodMin),
 			p.Duration(parameters.SSHKeepAlivePeriodMax))
@@ -1094,7 +1105,7 @@ func (tunnel *Tunnel) operateTunnel(tunnelOwner TunnelOwner) {
 			totalSent += sent
 			totalReceived += received
 
-			p := clientParameters.Get()
+			p := tunnel.getCustomClientParameters()
 			noticePeriod := p.Duration(parameters.TotalBytesTransferredNoticePeriod)
 			replayTargetUpstreamBytes := p.Int(parameters.ReplayTargetUpstreamBytes)
 			replayTargetDownstreamBytes := p.Int(parameters.ReplayTargetDownstreamBytes)
@@ -1133,9 +1144,10 @@ func (tunnel *Tunnel) operateTunnel(tunnelOwner TunnelOwner) {
 			statsTimer.Reset(nextStatusRequestPeriod())
 
 		case <-sshKeepAliveTimer.C:
-			inactivePeriod := clientParameters.Get().Duration(parameters.SSHKeepAlivePeriodicInactivePeriod)
+			p := tunnel.getCustomClientParameters()
+			inactivePeriod := p.Duration(parameters.SSHKeepAlivePeriodicInactivePeriod)
 			if lastBytesReceivedTime.Add(inactivePeriod).Before(monotime.Now()) {
-				timeout := clientParameters.Get().Duration(parameters.SSHKeepAlivePeriodicTimeout)
+				timeout := p.Duration(parameters.SSHKeepAlivePeriodicTimeout)
 				select {
 				case signalSshKeepAlive <- timeout:
 				default:
@@ -1157,9 +1169,10 @@ func (tunnel *Tunnel) operateTunnel(tunnelOwner TunnelOwner) {
 			if tunnel.conn.IsClosed() {
 				err = errors.New("underlying conn is closed")
 			} else {
-				inactivePeriod := clientParameters.Get().Duration(parameters.SSHKeepAliveProbeInactivePeriod)
+				p := tunnel.getCustomClientParameters()
+				inactivePeriod := p.Duration(parameters.SSHKeepAliveProbeInactivePeriod)
 				if lastBytesReceivedTime.Add(inactivePeriod).Before(monotime.Now()) {
-					timeout := clientParameters.Get().Duration(parameters.SSHKeepAliveProbeTimeout)
+					timeout := p.Duration(parameters.SSHKeepAliveProbeTimeout)
 					select {
 					case signalSshKeepAlive <- timeout:
 					default:
@@ -1243,7 +1256,7 @@ func (tunnel *Tunnel) sendSshKeepAlive(isFirstKeepAlive bool, timeout time.Durat
 
 	go func() {
 		// Random padding to frustrate fingerprinting.
-		p := tunnel.config.GetClientParametersSnapshot()
+		p := tunnel.getCustomClientParameters()
 		request := prng.Padding(
 			p.Int(parameters.SSHKeepAlivePaddingMinBytes),
 			p.Int(parameters.SSHKeepAlivePaddingMaxBytes))
@@ -1269,11 +1282,11 @@ func (tunnel *Tunnel) sendSshKeepAlive(isFirstKeepAlive bool, timeout time.Durat
 
 		if err == nil && requestOk &&
 			(isFirstKeepAlive ||
-				tunnel.config.GetClientParametersSnapshot().WeightedCoinFlip(
+				tunnel.getCustomClientParameters().WeightedCoinFlip(
 					parameters.SSHKeepAliveSpeedTestSampleProbability)) {
 
 			err = tactics.AddSpeedTestSample(
-				tunnel.config.clientParameters,
+				tunnel.config.GetClientParameters(),
 				GetTacticsStorer(),
 				tunnel.config.GetNetworkID(),
 				tunnel.dialParams.ServerEntry.Region,

+ 1 - 1
psiphon/upgradeDownload.go

@@ -73,7 +73,7 @@ func DownloadUpgrade(
 		return nil
 	}
 
-	p := config.GetClientParametersSnapshot()
+	p := config.GetClientParameters().Get()
 	urls := p.DownloadURLs(parameters.UpgradeDownloadURLs)
 	clientVersionHeader := p.String(parameters.UpgradeDownloadClientVersionHeader)
 	downloadTimeout := p.Duration(parameters.FetchUpgradeTimeout)