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

Inproxy personal pairing enhancements

- Add InproxyPersonalPairingConnectionWorkerPoolSize, with a smaller default
  size than ConnectionWorkerPoolSize.

- Rate limit in-proxy client dials; wait when only in-proxy tunnel protocols
  may be selected; otherwise skip in-proxy tunnel protocols.

- Force an untunneled tactics fetch in specific in-proxy scenarios where
  tactics are required.

- Enable in-proxy client tests, which will exercise the force tactics fetch.

- Add optional, personal pairing-specific broker specs.

- Remove Tactics.Probability. Now there are many tactics parameters --
  ApplicationParameters, in-proxy BrokerSpecs, provider restrictions, Burst
  targets, TLS fingerprint/CDN compatibility, and more -- for which it no
  longer makes sense to apply all tactics only with some probability.
  Individual parameters can still be applied with a coin flip by using or
  adding companion probability parameters.

- Add more explicit IsPersonalPairing helper function.

- Add temporary [client_]session_id workaround for compatibility with the
  current network.
Rod Hynes 1 год назад
Родитель
Сommit
bddc482a65

+ 13 - 1
psiphon/common/parameters/parameters.go

@@ -375,8 +375,11 @@ const (
 	InproxyTunnelProtocolSelectionProbability          = "InproxyTunnelProtocolSelectionProbability"
 	InproxyTunnelProtocolSelectionProbability          = "InproxyTunnelProtocolSelectionProbability"
 	InproxyAllBrokerPublicKeys                         = "InproxyAllBrokerPublicKeys"
 	InproxyAllBrokerPublicKeys                         = "InproxyAllBrokerPublicKeys"
 	InproxyBrokerSpecs                                 = "InproxyBrokerSpecs"
 	InproxyBrokerSpecs                                 = "InproxyBrokerSpecs"
+	InproxyPersonalPairingBrokerSpecs                  = "InproxyPersonalPairingBrokerSpecs"
 	InproxyProxyBrokerSpecs                            = "InproxyProxyBrokerSpecs"
 	InproxyProxyBrokerSpecs                            = "InproxyProxyBrokerSpecs"
+	InproxyProxyPersonalPairingBrokerSpecs             = "InproxyProxyPersonalPairingBrokerSpecs"
 	InproxyClientBrokerSpecs                           = "InproxyClientBrokerSpecs"
 	InproxyClientBrokerSpecs                           = "InproxyClientBrokerSpecs"
+	InproxyClientPersonalPairingBrokerSpecs            = "InproxyClientPersonalPairingBrokerSpecs"
 	InproxyReplayBrokerDialParametersTTL               = "InproxyReplayBrokerDialParametersTTL"
 	InproxyReplayBrokerDialParametersTTL               = "InproxyReplayBrokerDialParametersTTL"
 	InproxyReplayBrokerUpdateFrequency                 = "InproxyReplayBrokerUpdateFrequency"
 	InproxyReplayBrokerUpdateFrequency                 = "InproxyReplayBrokerUpdateFrequency"
 	InproxyReplayBrokerDialParametersProbability       = "InproxyReplayBrokerDialParametersProbability"
 	InproxyReplayBrokerDialParametersProbability       = "InproxyReplayBrokerDialParametersProbability"
@@ -434,6 +437,9 @@ const (
 	InproxyProxyDestinationDialTimeout                 = "InproxyProxyDestinationDialTimeout"
 	InproxyProxyDestinationDialTimeout                 = "InproxyProxyDestinationDialTimeout"
 	InproxyPsiphonAPIRequestTimeout                    = "InproxyPsiphonAPIRequestTimeout"
 	InproxyPsiphonAPIRequestTimeout                    = "InproxyPsiphonAPIRequestTimeout"
 	InproxyProxyTotalActivityNoticePeriod              = "InproxyProxyTotalActivityNoticePeriod"
 	InproxyProxyTotalActivityNoticePeriod              = "InproxyProxyTotalActivityNoticePeriod"
+	InproxyPersonalPairingConnectionWorkerPoolSize     = "InproxyPersonalPairingConnectionWorkerPoolSize"
+	InproxyClientDialRateLimitQuantity                 = "InproxyClientDialRateLimitQuantity"
+	InproxyClientDialRateLimitInterval                 = "InproxyClientDialRateLimitInterval"
 
 
 	// Retired parameters
 	// Retired parameters
 
 
@@ -867,8 +873,11 @@ var defaultParameters = map[string]struct {
 	InproxyTunnelProtocolSelectionProbability:          {value: 0.5, minimum: 0.0},
 	InproxyTunnelProtocolSelectionProbability:          {value: 0.5, minimum: 0.0},
 	InproxyAllBrokerPublicKeys:                         {value: []string{}, flags: serverSideOnly},
 	InproxyAllBrokerPublicKeys:                         {value: []string{}, flags: serverSideOnly},
 	InproxyBrokerSpecs:                                 {value: InproxyBrokerSpecsValue{}},
 	InproxyBrokerSpecs:                                 {value: InproxyBrokerSpecsValue{}},
+	InproxyPersonalPairingBrokerSpecs:                  {value: InproxyBrokerSpecsValue{}},
 	InproxyProxyBrokerSpecs:                            {value: InproxyBrokerSpecsValue{}},
 	InproxyProxyBrokerSpecs:                            {value: InproxyBrokerSpecsValue{}},
+	InproxyProxyPersonalPairingBrokerSpecs:             {value: InproxyBrokerSpecsValue{}},
 	InproxyClientBrokerSpecs:                           {value: InproxyBrokerSpecsValue{}},
 	InproxyClientBrokerSpecs:                           {value: InproxyBrokerSpecsValue{}},
+	InproxyClientPersonalPairingBrokerSpecs:            {value: InproxyBrokerSpecsValue{}},
 	InproxyReplayBrokerDialParametersTTL:               {value: 24 * time.Hour, minimum: time.Duration(0)},
 	InproxyReplayBrokerDialParametersTTL:               {value: 24 * time.Hour, minimum: time.Duration(0)},
 	InproxyReplayBrokerUpdateFrequency:                 {value: 5 * time.Minute, minimum: time.Duration(0)},
 	InproxyReplayBrokerUpdateFrequency:                 {value: 5 * time.Minute, minimum: time.Duration(0)},
 	InproxyReplayBrokerDialParametersProbability:       {value: 1.0, minimum: 0.0},
 	InproxyReplayBrokerDialParametersProbability:       {value: 1.0, minimum: 0.0},
@@ -882,7 +891,7 @@ var defaultParameters = map[string]struct {
 	InproxyBrokerMatcherAnnouncementNonlimitedProxyIDs: {value: []string{}, flags: serverSideOnly},
 	InproxyBrokerMatcherAnnouncementNonlimitedProxyIDs: {value: []string{}, flags: serverSideOnly},
 	InproxyBrokerMatcherOfferLimitEntryCount:           {value: 10, minimum: 0, flags: serverSideOnly},
 	InproxyBrokerMatcherOfferLimitEntryCount:           {value: 10, minimum: 0, flags: serverSideOnly},
 	InproxyBrokerMatcherOfferRateLimitQuantity:         {value: 50, minimum: 0, flags: serverSideOnly},
 	InproxyBrokerMatcherOfferRateLimitQuantity:         {value: 50, minimum: 0, flags: serverSideOnly},
-	InproxyBrokerMatcherOfferRateLimitInterval:         {value: 1 * time.Minute, minimum: time.Duration(0)},
+	InproxyBrokerMatcherOfferRateLimitInterval:         {value: 1 * time.Minute, minimum: time.Duration(0), flags: serverSideOnly},
 	InproxyBrokerProxyAnnounceTimeout:                  {value: 2 * time.Minute, minimum: time.Duration(0), flags: serverSideOnly},
 	InproxyBrokerProxyAnnounceTimeout:                  {value: 2 * time.Minute, minimum: time.Duration(0), flags: serverSideOnly},
 	InproxyBrokerClientOfferTimeout:                    {value: 10 * time.Second, minimum: time.Duration(0), flags: serverSideOnly},
 	InproxyBrokerClientOfferTimeout:                    {value: 10 * time.Second, minimum: time.Duration(0), flags: serverSideOnly},
 	InproxyBrokerPendingServerRequestsTTL:              {value: 60 * time.Second, minimum: time.Duration(0), flags: serverSideOnly},
 	InproxyBrokerPendingServerRequestsTTL:              {value: 60 * time.Second, minimum: time.Duration(0), flags: serverSideOnly},
@@ -926,6 +935,9 @@ var defaultParameters = map[string]struct {
 	InproxyProxyDestinationDialTimeout:                 {value: 20 * time.Second, minimum: time.Duration(0), flags: useNetworkLatencyMultiplier},
 	InproxyProxyDestinationDialTimeout:                 {value: 20 * time.Second, minimum: time.Duration(0), flags: useNetworkLatencyMultiplier},
 	InproxyPsiphonAPIRequestTimeout:                    {value: 10 * time.Second, minimum: 1 * time.Second, flags: useNetworkLatencyMultiplier},
 	InproxyPsiphonAPIRequestTimeout:                    {value: 10 * time.Second, minimum: 1 * time.Second, flags: useNetworkLatencyMultiplier},
 	InproxyProxyTotalActivityNoticePeriod:              {value: 5 * time.Minute, minimum: 1 * time.Second},
 	InproxyProxyTotalActivityNoticePeriod:              {value: 5 * time.Minute, minimum: 1 * time.Second},
+	InproxyPersonalPairingConnectionWorkerPoolSize:     {value: 2, minimum: 1},
+	InproxyClientDialRateLimitQuantity:                 {value: 10, minimum: 0},
+	InproxyClientDialRateLimitInterval:                 {value: 1 * time.Minute, minimum: time.Duration(0)},
 }
 }
 
 
 // IsServerSideOnly indicates if the parameter specified by name is used
 // IsServerSideOnly indicates if the parameter specified by name is used

+ 10 - 1
psiphon/common/protocol/protocol.go

@@ -146,7 +146,7 @@ func (t TunnelProtocols) PruneInvalid() TunnelProtocols {
 	return u
 	return u
 }
 }
 
 
-func (t TunnelProtocols) OnlyInproxyTunnelProtocols() TunnelProtocols {
+func (t TunnelProtocols) PruneNonInproxyTunnelProtocols() TunnelProtocols {
 	u := make(TunnelProtocols, 0)
 	u := make(TunnelProtocols, 0)
 	for _, p := range t {
 	for _, p := range t {
 		if TunnelProtocolUsesInproxy(p) {
 		if TunnelProtocolUsesInproxy(p) {
@@ -156,6 +156,15 @@ func (t TunnelProtocols) OnlyInproxyTunnelProtocols() TunnelProtocols {
 	return u
 	return u
 }
 }
 
 
+func (t TunnelProtocols) IsOnlyInproxyTunnelProtocols() bool {
+	for _, p := range t {
+		if !TunnelProtocolUsesInproxy(p) {
+			return false
+		}
+	}
+	return true
+}
+
 type LabeledTunnelProtocols map[string]TunnelProtocols
 type LabeledTunnelProtocols map[string]TunnelProtocols
 
 
 func (labeledProtocols LabeledTunnelProtocols) Validate() error {
 func (labeledProtocols LabeledTunnelProtocols) Validate() error {

+ 4 - 29
psiphon/common/tactics/tactics.go

@@ -112,12 +112,6 @@ tactics. Each time the tactics changes, this process is repeated so that
 obsolete tactics parameters are not retained in the client's Parameters
 obsolete tactics parameters are not retained in the client's Parameters
 instance.
 instance.
 
 
-Tactics has a probability parameter that is used in a weighted coin flip to
-determine if the tactics is to be applied or skipped for the current client
-session. This allows for experimenting with provisional tactics; and obtaining
-non-tactic sample metrics in situations which would otherwise always use a
-tactic.
-
 Speed test data is used in filtered tactics for selection of parameters such as
 Speed test data is used in filtered tactics for selection of parameters such as
 timeouts.
 timeouts.
 
 
@@ -217,8 +211,8 @@ var (
 // matching filter are merged into the client tactics.
 // matching filter are merged into the client tactics.
 //
 //
 // The merge operation replaces any existing item in Parameter with a Parameter specified in
 // The merge operation replaces any existing item in Parameter with a Parameter specified in
-// the newest matching tactics. The TTL and Probability of the newest matching tactics is taken,
-// although all but the DefaultTactics can omit the TTL and Probability fields.
+// the newest matching tactics. The TTL of the newest matching tactics is taken, although all
+// but the DefaultTactics can omit the TTL field.
 type Server struct {
 type Server struct {
 	common.ReloadableFile
 	common.ReloadableFile
 
 
@@ -232,7 +226,7 @@ type Server struct {
 	RequestObfuscatedKey []byte
 	RequestObfuscatedKey []byte
 
 
 	// DefaultTactics is the baseline tactics for all clients. It must include a
 	// DefaultTactics is the baseline tactics for all clients. It must include a
-	// TTL and Probability.
+	// TTL.
 	DefaultTactics Tactics
 	DefaultTactics Tactics
 
 
 	// FilteredTactics is an ordered list of filter/tactics pairs. For a client,
 	// FilteredTactics is an ordered list of filter/tactics pairs. For a client,
@@ -363,10 +357,6 @@ type Tactics struct {
 	// no tactics data when the tag is unchanged.
 	// no tactics data when the tag is unchanged.
 	TTL string
 	TTL string
 
 
-	// Probability specifies the probability [0.0 - 1.0] with which
-	// the client should apply the tactics in a new session.
-	Probability float64
-
 	// Parameters specify client parameters to override. These must
 	// Parameters specify client parameters to override. These must
 	// be a subset of parameter.ClientParameter values and follow
 	// be a subset of parameter.ClientParameter values and follow
 	// the corresponding data type and minimum value constraints.
 	// the corresponding data type and minimum value constraints.
@@ -540,13 +530,6 @@ func (server *Server) Validate() error {
 			tactics.TTL = ""
 			tactics.TTL = ""
 		}
 		}
 
 
-		if (validatingDefault && tactics.Probability == 0.0) ||
-			tactics.Probability < 0.0 ||
-			tactics.Probability > 1.0 {
-
-			return errors.TraceNew("invalid probability")
-		}
-
 		params, err := parameters.NewParameters(nil)
 		params, err := parameters.NewParameters(nil)
 		if err != nil {
 		if err != nil {
 			return errors.Trace(err)
 			return errors.Trace(err)
@@ -1079,8 +1062,7 @@ func medianSampleRTTMilliseconds(samples []SpeedTestSample) int {
 func (t *Tactics) clone(includeServerSideOnly bool) *Tactics {
 func (t *Tactics) clone(includeServerSideOnly bool) *Tactics {
 
 
 	u := &Tactics{
 	u := &Tactics{
-		TTL:         t.TTL,
-		Probability: t.Probability,
+		TTL: t.TTL,
 	}
 	}
 
 
 	// Note: there is no deep copy of parameter values; the the returned
 	// Note: there is no deep copy of parameter values; the the returned
@@ -1104,10 +1086,6 @@ func (t *Tactics) merge(includeServerSideOnly bool, u *Tactics) {
 		t.TTL = u.TTL
 		t.TTL = u.TTL
 	}
 	}
 
 
-	if u.Probability != 0.0 {
-		t.Probability = u.Probability
-	}
-
 	// Note: there is no deep copy of parameter values; the the returned
 	// Note: there is no deep copy of parameter values; the the returned
 	// Tactics shares memory with the original and its individual parameters
 	// Tactics shares memory with the original and its individual parameters
 	// should not be modified.
 	// should not be modified.
@@ -1744,9 +1722,6 @@ func applyTacticsPayload(
 	if ttl <= 0 {
 	if ttl <= 0 {
 		return newTactics, errors.TraceNew("invalid TTL")
 		return newTactics, errors.TraceNew("invalid TTL")
 	}
 	}
-	if record.Tactics.Probability <= 0.0 {
-		return newTactics, errors.TraceNew("invalid probability")
-	}
 
 
 	// Set or extend the expiry.
 	// Set or extend the expiry.
 
 

+ 1 - 11
psiphon/common/tactics/tactics_test.go

@@ -56,7 +56,6 @@ func TestTactics(t *testing.T) {
       "RequestObfuscatedKey" : "%s",
       "RequestObfuscatedKey" : "%s",
       "DefaultTactics" : {
       "DefaultTactics" : {
         "TTL" : "1s",
         "TTL" : "1s",
-        "Probability" : %0.1f,
         "Parameters" : {
         "Parameters" : {
           "NetworkLatencyMultiplier" : %0.1f,
           "NetworkLatencyMultiplier" : %0.1f,
           "ServerPacketManipulationSpecs" : [{"Name": "test-packetman-spec", "PacketSpecs": [["TCP-flags S"]]}]
           "ServerPacketManipulationSpecs" : [{"Name": "test-packetman-spec", "PacketSpecs": [["TCP-flags S"]]}]
@@ -126,7 +125,6 @@ func TestTactics(t *testing.T) {
 		t.Fatalf("GenerateKeys failed: %s", err)
 		t.Fatalf("GenerateKeys failed: %s", err)
 	}
 	}
 
 
-	tacticsProbability := 0.5
 	tacticsNetworkLatencyMultiplier := 2.0
 	tacticsNetworkLatencyMultiplier := 2.0
 	tacticsConnectionWorkerPoolSize := 5
 	tacticsConnectionWorkerPoolSize := 5
 	tacticsLimitTunnelProtocols := protocol.TunnelProtocols{"OSSH", "SSH"}
 	tacticsLimitTunnelProtocols := protocol.TunnelProtocols{"OSSH", "SSH"}
@@ -139,7 +137,6 @@ func TestTactics(t *testing.T) {
 		encodedRequestPublicKey,
 		encodedRequestPublicKey,
 		encodedRequestPrivateKey,
 		encodedRequestPrivateKey,
 		encodedObfuscatedKey,
 		encodedObfuscatedKey,
-		tacticsProbability,
 		tacticsNetworkLatencyMultiplier,
 		tacticsNetworkLatencyMultiplier,
 		tacticsConnectionWorkerPoolSize,
 		tacticsConnectionWorkerPoolSize,
 		jsonTacticsLimitTunnelProtocols,
 		jsonTacticsLimitTunnelProtocols,
@@ -300,10 +297,6 @@ func TestTactics(t *testing.T) {
 			t.Fatalf("NewParameters failed: %s", err)
 			t.Fatalf("NewParameters failed: %s", err)
 		}
 		}
 
 
-		if r.Tactics.Probability != tacticsProbability {
-			t.Fatalf("Unexpected probability: %f", r.Tactics.Probability)
-		}
-
 		// ValidationSkipOnError is set for Psiphon clients
 		// ValidationSkipOnError is set for Psiphon clients
 		counts, err := p.Set(r.Tag, parameters.ValidationSkipOnError, r.Tactics.Parameters)
 		counts, err := p.Set(r.Tag, parameters.ValidationSkipOnError, r.Tactics.Parameters)
 		if err != nil {
 		if err != nil {
@@ -462,7 +455,6 @@ func TestTactics(t *testing.T) {
 		encodedRequestPublicKey,
 		encodedRequestPublicKey,
 		encodedRequestPrivateKey,
 		encodedRequestPrivateKey,
 		encodedObfuscatedKey,
 		encodedObfuscatedKey,
-		tacticsProbability,
 		tacticsNetworkLatencyMultiplier,
 		tacticsNetworkLatencyMultiplier,
 		tacticsConnectionWorkerPoolSize,
 		tacticsConnectionWorkerPoolSize,
 		jsonTacticsLimitTunnelProtocols,
 		jsonTacticsLimitTunnelProtocols,
@@ -689,7 +681,6 @@ func TestTactics(t *testing.T) {
 		"",
 		"",
 		"",
 		"",
 		"",
 		"",
-		tacticsProbability,
 		tacticsNetworkLatencyMultiplier,
 		tacticsNetworkLatencyMultiplier,
 		tacticsConnectionWorkerPoolSize,
 		tacticsConnectionWorkerPoolSize,
 		jsonTacticsLimitTunnelProtocols,
 		jsonTacticsLimitTunnelProtocols,
@@ -741,8 +732,7 @@ func TestTacticsFilterGeoIPScope(t *testing.T) {
       "RequestPrivateKey" : "%s",
       "RequestPrivateKey" : "%s",
       "RequestObfuscatedKey" : "%s",
       "RequestObfuscatedKey" : "%s",
       "DefaultTactics" : {
       "DefaultTactics" : {
-        "TTL" : "60s",
-        "Probability" : 1.0
+        "TTL" : "60s"
       },
       },
       %%s
       %%s
     }
     }

+ 67 - 29
psiphon/config.go

@@ -226,7 +226,7 @@ type Config struct {
 	EstablishTunnelServerAffinityGracePeriodMilliseconds *int
 	EstablishTunnelServerAffinityGracePeriodMilliseconds *int
 
 
 	// ConnectionWorkerPoolSize specifies how many connection attempts to
 	// ConnectionWorkerPoolSize specifies how many connection attempts to
-	// attempt in parallel. If omitted of when 0, a default is used; this is
+	// attempt in parallel. If omitted or when 0, a default is used; this is
 	// recommended.
 	// recommended.
 	ConnectionWorkerPoolSize int
 	ConnectionWorkerPoolSize int
 
 
@@ -665,38 +665,28 @@ type Config struct {
 	//
 	//
 	// Limitations:
 	// Limitations:
 	//
 	//
-	// - While fully functional, the personal pairing mode has a number of
-	//   limitations that make the current implementation less suitable for
-	//   large scale deployment.
+	// While fully functional, the personal pairing mode has a number of
+	// limitations that make the current implementation less suitable for
+	// large scale deployment.
 	//
 	//
-	// - Since the mode requires an in-proxy connection to a proxy, announcing
-	//   with the corresponding personal compartment ID, not only must that
-	//   proxy be available, but also a broker, and both the client and proxy
-	//   must rendezvous at the same broker.
+	// Since the mode requires an in-proxy connection to a proxy, announcing
+	// with the corresponding personal compartment ID, not only must that
+	// proxy be available, but also a broker, and both the client and proxy
+	// must rendezvous at the same broker.
 	//
 	//
-	// - Currently, the client tunnel establishment algorithm does not launch
-	//   an untunneled tactics request as long as there is a cached tactics
-	//   with a valid TTL. The assumption, in regular mode, is that the
-	//   cached tactics will suffice, and any new tactics will be obtained
-	//   from any Psiphon server connection. Since broker specs are obtained
-	//   solely from tactics, if brokers are removed, reconfigured, or even
-	//   if the order is changed, personal mode may fail to connect until
-	//   cached tactics expire.
-	//
-	// - In personal mode, clients and proxies use a simplistic approach to
-	//   rendezvous: always select the first broker spec. This works, but is
-	//   not robust in terms of load balancing, and fails if the first broker
-	//   is unreachable or overloaded. Non-personal in-proxy dials can simply
-	//   use any available broker.
-	//
-	// - In personal mode, all establishment candidates must be in-proxy
-	//   dials, all using the same broker. Many concurrent, fronted broker
-	//   requests may result in CDN rate limiting, requiring some mechanism
-	//   to delay or spread the requests, as is currently done only for
-	//   batches of proxy announcements.
+	// In personal mode, clients and proxies use a simplistic approach to
+	// rendezvous: always select the first broker spec. This works, but is
+	// not robust in terms of load balancing, and fails if the first broker
+	// is unreachable or overloaded. Non-personal in-proxy dials can simply
+	// use any available broker.
 	//
 	//
 	InproxyClientPersonalCompartmentIDs []string
 	InproxyClientPersonalCompartmentIDs []string
 
 
+	// InproxyPersonalPairingConnectionWorkerPoolSize specifies the value for
+	// ConnectionWorkerPoolSize in personal pairing mode. If omitted or when
+	// 0, a default is used; this is recommended.
+	InproxyPersonalPairingConnectionWorkerPoolSize int
+
 	// EmitInproxyProxyActivity indicates whether to emit frequent notices
 	// EmitInproxyProxyActivity indicates whether to emit frequent notices
 	// showing proxy connection information and bytes transferred.
 	// showing proxy connection information and bytes transferred.
 	EmitInproxyProxyActivity bool
 	EmitInproxyProxyActivity bool
@@ -999,8 +989,11 @@ type Config struct {
 	InproxyAllowClient                                     *bool
 	InproxyAllowClient                                     *bool
 	InproxyTunnelProtocolSelectionProbability              *float64
 	InproxyTunnelProtocolSelectionProbability              *float64
 	InproxyBrokerSpecs                                     parameters.InproxyBrokerSpecsValue
 	InproxyBrokerSpecs                                     parameters.InproxyBrokerSpecsValue
-	InproxyClientBrokerSpecs                               parameters.InproxyBrokerSpecsValue
+	InproxyPersonalPairingBrokerSpecs                      parameters.InproxyBrokerSpecsValue
 	InproxyProxyBrokerSpecs                                parameters.InproxyBrokerSpecsValue
 	InproxyProxyBrokerSpecs                                parameters.InproxyBrokerSpecsValue
+	InproxyProxyPersonalPairingBrokerSpecs                 parameters.InproxyBrokerSpecsValue
+	InproxyClientBrokerSpecs                               parameters.InproxyBrokerSpecsValue
+	InproxyClientPersonalPairingBrokerSpecs                parameters.InproxyBrokerSpecsValue
 	InproxyReplayBrokerDialParametersTTLSeconds            *int
 	InproxyReplayBrokerDialParametersTTLSeconds            *int
 	InproxyReplayBrokerUpdateFrequencySeconds              *int
 	InproxyReplayBrokerUpdateFrequencySeconds              *int
 	InproxyReplayBrokerDialParametersProbability           *float64
 	InproxyReplayBrokerDialParametersProbability           *float64
@@ -1045,6 +1038,8 @@ type Config struct {
 	InproxyProxyDestinationDialTimeoutMilliseconds         *int
 	InproxyProxyDestinationDialTimeoutMilliseconds         *int
 	InproxyPsiphonAPIRequestTimeoutMilliseconds            *int
 	InproxyPsiphonAPIRequestTimeoutMilliseconds            *int
 	InproxyProxyTotalActivityNoticePeriodMilliseconds      *int
 	InproxyProxyTotalActivityNoticePeriodMilliseconds      *int
+	InproxyClientDialRateLimitQuantity                     *int
+	InproxyClientDialRateLimitIntervalMilliseconds         *int
 
 
 	InproxySkipAwaitFullyConnected  bool
 	InproxySkipAwaitFullyConnected  bool
 	InproxyEnableWebRTCDebugLogging bool
 	InproxyEnableWebRTCDebugLogging bool
@@ -1787,6 +1782,13 @@ func (config *Config) GetNetworkID() string {
 	return config.networkIDGetter.GetNetworkID()
 	return config.networkIDGetter.GetNetworkID()
 }
 }
 
 
+// IsInproxyPersonalPairingMode indicates that the client is in in-proxy
+// personal pairing mode, where connections are made only through in-proxy
+// proxies with corresponding personal compartment IDs.
+func (config *Config) IsInproxyPersonalPairingMode() bool {
+	return len(config.InproxyClientPersonalCompartmentIDs) > 0
+}
+
 func (config *Config) makeConfigParameters() map[string]interface{} {
 func (config *Config) makeConfigParameters() map[string]interface{} {
 
 
 	// Build set of config values to apply to parameters.
 	// Build set of config values to apply to parameters.
@@ -2376,6 +2378,10 @@ func (config *Config) makeConfigParameters() map[string]interface{} {
 		applyParameters[parameters.SteeringIPProbability] = *config.SteeringIPProbability
 		applyParameters[parameters.SteeringIPProbability] = *config.SteeringIPProbability
 	}
 	}
 
 
+	if config.InproxyPersonalPairingConnectionWorkerPoolSize != 0 {
+		applyParameters[parameters.InproxyPersonalPairingConnectionWorkerPoolSize] = config.InproxyPersonalPairingConnectionWorkerPoolSize
+	}
+
 	if config.InproxyAllowProxy != nil {
 	if config.InproxyAllowProxy != nil {
 		applyParameters[parameters.InproxyAllowProxy] = *config.InproxyAllowProxy
 		applyParameters[parameters.InproxyAllowProxy] = *config.InproxyAllowProxy
 	}
 	}
@@ -2392,14 +2398,26 @@ func (config *Config) makeConfigParameters() map[string]interface{} {
 		applyParameters[parameters.InproxyBrokerSpecs] = config.InproxyBrokerSpecs
 		applyParameters[parameters.InproxyBrokerSpecs] = config.InproxyBrokerSpecs
 	}
 	}
 
 
+	if len(config.InproxyPersonalPairingBrokerSpecs) > 0 {
+		applyParameters[parameters.InproxyPersonalPairingBrokerSpecs] = config.InproxyPersonalPairingBrokerSpecs
+	}
+
 	if len(config.InproxyProxyBrokerSpecs) > 0 {
 	if len(config.InproxyProxyBrokerSpecs) > 0 {
 		applyParameters[parameters.InproxyProxyBrokerSpecs] = config.InproxyProxyBrokerSpecs
 		applyParameters[parameters.InproxyProxyBrokerSpecs] = config.InproxyProxyBrokerSpecs
 	}
 	}
 
 
+	if len(config.InproxyProxyPersonalPairingBrokerSpecs) > 0 {
+		applyParameters[parameters.InproxyProxyPersonalPairingBrokerSpecs] = config.InproxyProxyPersonalPairingBrokerSpecs
+	}
+
 	if len(config.InproxyClientBrokerSpecs) > 0 {
 	if len(config.InproxyClientBrokerSpecs) > 0 {
 		applyParameters[parameters.InproxyClientBrokerSpecs] = config.InproxyClientBrokerSpecs
 		applyParameters[parameters.InproxyClientBrokerSpecs] = config.InproxyClientBrokerSpecs
 	}
 	}
 
 
+	if len(config.InproxyClientPersonalPairingBrokerSpecs) > 0 {
+		applyParameters[parameters.InproxyClientPersonalPairingBrokerSpecs] = config.InproxyClientPersonalPairingBrokerSpecs
+	}
+
 	if config.InproxyReplayBrokerDialParametersTTLSeconds != nil {
 	if config.InproxyReplayBrokerDialParametersTTLSeconds != nil {
 		applyParameters[parameters.InproxyReplayBrokerDialParametersTTL] = fmt.Sprintf("%ds", *config.InproxyReplayBrokerDialParametersTTLSeconds)
 		applyParameters[parameters.InproxyReplayBrokerDialParametersTTL] = fmt.Sprintf("%ds", *config.InproxyReplayBrokerDialParametersTTLSeconds)
 	}
 	}
@@ -2576,6 +2594,14 @@ func (config *Config) makeConfigParameters() map[string]interface{} {
 		applyParameters[parameters.InproxyProxyTotalActivityNoticePeriod] = fmt.Sprintf("%dms", *config.InproxyProxyTotalActivityNoticePeriodMilliseconds)
 		applyParameters[parameters.InproxyProxyTotalActivityNoticePeriod] = fmt.Sprintf("%dms", *config.InproxyProxyTotalActivityNoticePeriodMilliseconds)
 	}
 	}
 
 
+	if config.InproxyClientDialRateLimitQuantity != nil {
+		applyParameters[parameters.InproxyClientDialRateLimitQuantity] = *config.InproxyClientDialRateLimitQuantity
+	}
+
+	if config.InproxyClientDialRateLimitIntervalMilliseconds != nil {
+		applyParameters[parameters.InproxyClientDialRateLimitInterval] = fmt.Sprintf("%dms", *config.InproxyClientDialRateLimitIntervalMilliseconds)
+	}
+
 	// When adding new config dial parameters that may override tactics, also
 	// When adding new config dial parameters that may override tactics, also
 	// update setDialParametersHash.
 	// update setDialParametersHash.
 
 
@@ -3184,14 +3210,26 @@ func (config *Config) setDialParametersHash() {
 		hash.Write([]byte("InproxyBrokerSpecs"))
 		hash.Write([]byte("InproxyBrokerSpecs"))
 		hash.Write([]byte(fmt.Sprintf("%+v", config.InproxyBrokerSpecs)))
 		hash.Write([]byte(fmt.Sprintf("%+v", config.InproxyBrokerSpecs)))
 	}
 	}
+	if len(config.InproxyPersonalPairingBrokerSpecs) > 0 {
+		hash.Write([]byte("InproxyPersonalPairingBrokerSpecs"))
+		hash.Write([]byte(fmt.Sprintf("%+v", config.InproxyPersonalPairingBrokerSpecs)))
+	}
 	if len(config.InproxyProxyBrokerSpecs) > 0 {
 	if len(config.InproxyProxyBrokerSpecs) > 0 {
 		hash.Write([]byte("InproxyProxyBrokerSpecs"))
 		hash.Write([]byte("InproxyProxyBrokerSpecs"))
 		hash.Write([]byte(fmt.Sprintf("%+v", config.InproxyProxyBrokerSpecs)))
 		hash.Write([]byte(fmt.Sprintf("%+v", config.InproxyProxyBrokerSpecs)))
 	}
 	}
+	if len(config.InproxyProxyPersonalPairingBrokerSpecs) > 0 {
+		hash.Write([]byte("InproxyProxyPersonalPairingBrokerSpecs"))
+		hash.Write([]byte(fmt.Sprintf("%+v", config.InproxyProxyPersonalPairingBrokerSpecs)))
+	}
 	if len(config.InproxyClientBrokerSpecs) > 0 {
 	if len(config.InproxyClientBrokerSpecs) > 0 {
 		hash.Write([]byte("InproxyClientBrokerSpecs"))
 		hash.Write([]byte("InproxyClientBrokerSpecs"))
 		hash.Write([]byte(fmt.Sprintf("%+v", config.InproxyClientBrokerSpecs)))
 		hash.Write([]byte(fmt.Sprintf("%+v", config.InproxyClientBrokerSpecs)))
 	}
 	}
+	if len(config.InproxyClientPersonalPairingBrokerSpecs) > 0 {
+		hash.Write([]byte("InproxyClientPersonalPairingBrokerSpecs"))
+		hash.Write([]byte(fmt.Sprintf("%+v", config.InproxyClientPersonalPairingBrokerSpecs)))
+	}
 	if config.InproxyReplayBrokerDialParametersTTLSeconds != nil {
 	if config.InproxyReplayBrokerDialParametersTTLSeconds != nil {
 		hash.Write([]byte("InproxyReplayBrokerDialParametersTTLSeconds"))
 		hash.Write([]byte("InproxyReplayBrokerDialParametersTTLSeconds"))
 		binary.Write(hash, binary.LittleEndian, int64(*config.InproxyReplayBrokerDialParametersTTLSeconds))
 		binary.Write(hash, binary.LittleEndian, int64(*config.InproxyReplayBrokerDialParametersTTLSeconds))

+ 252 - 32
psiphon/controller.go

@@ -44,6 +44,7 @@ import (
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/tactics"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/tactics"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/tun"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/tun"
 	lrucache "github.com/cognusion/go-cache-lru"
 	lrucache "github.com/cognusion/go-cache-lru"
+	"golang.org/x/time/rate"
 )
 )
 
 
 // Controller is a tunnel lifecycle coordinator. It manages lists of servers to
 // Controller is a tunnel lifecycle coordinator. It manages lists of servers to
@@ -96,6 +97,8 @@ type Controller struct {
 	inproxyNATStateManager                  *InproxyNATStateManager
 	inproxyNATStateManager                  *InproxyNATStateManager
 	inproxyHandleTacticsMutex               sync.Mutex
 	inproxyHandleTacticsMutex               sync.Mutex
 	inproxyLastStoredTactics                time.Time
 	inproxyLastStoredTactics                time.Time
+	establishSignalForceTacticsFetch        chan struct{}
+	inproxyClientDialRateLimiter            *rate.Limiter
 }
 }
 
 
 // NewController initializes a new controller.
 // NewController initializes a new controller.
@@ -116,7 +119,7 @@ func NewController(config *Config) (controller *Controller, err error) {
 	// ensures no tactics request is attempted now.
 	// ensures no tactics request is attempted now.
 	doneContext, cancelFunc := context.WithCancel(context.Background())
 	doneContext, cancelFunc := context.WithCancel(context.Background())
 	cancelFunc()
 	cancelFunc()
-	GetTactics(doneContext, config)
+	GetTactics(doneContext, config, true)
 
 
 	p := config.GetParameters().Get()
 	p := config.GetParameters().Get()
 	splitTunnelClassificationTTL :=
 	splitTunnelClassificationTTL :=
@@ -1505,6 +1508,8 @@ type protocolSelectionConstraints struct {
 	limitTunnelDialPortNumbers                protocol.TunnelProtocolPortLists
 	limitTunnelDialPortNumbers                protocol.TunnelProtocolPortLists
 	limitQUICVersions                         protocol.QUICVersions
 	limitQUICVersions                         protocol.QUICVersions
 	replayCandidateCount                      int
 	replayCandidateCount                      int
+	isInproxyPersonalPairingMode              bool
+	inproxyClientDialRateLimiter              *rate.Limiter
 }
 }
 
 
 func (p *protocolSelectionConstraints) hasInitialProtocols() bool {
 func (p *protocolSelectionConstraints) hasInitialProtocols() bool {
@@ -1556,24 +1561,30 @@ func (p *protocolSelectionConstraints) canReplay(
 		replayProtocol)
 		replayProtocol)
 }
 }
 
 
-func (p *protocolSelectionConstraints) supportedProtocols(
-	connectTunnelCount int,
-	excludeIntensive bool,
-	excludeInproxy bool,
-	serverEntry *protocol.ServerEntry) []string {
+func (p *protocolSelectionConstraints) getLimitTunnelProtocols(
+	connectTunnelCount int) protocol.TunnelProtocols {
 
 
-	limitTunnelProtocols := p.limitTunnelProtocols
+	protocols := p.limitTunnelProtocols
 
 
 	if len(p.initialLimitTunnelProtocols) > 0 &&
 	if len(p.initialLimitTunnelProtocols) > 0 &&
 		p.initialLimitTunnelProtocolsCandidateCount > connectTunnelCount {
 		p.initialLimitTunnelProtocolsCandidateCount > connectTunnelCount {
 
 
-		limitTunnelProtocols = p.initialLimitTunnelProtocols
+		protocols = p.initialLimitTunnelProtocols
 	}
 	}
 
 
+	return protocols
+}
+
+func (p *protocolSelectionConstraints) supportedProtocols(
+	connectTunnelCount int,
+	excludeIntensive bool,
+	excludeInproxy bool,
+	serverEntry *protocol.ServerEntry) []string {
+
 	return serverEntry.GetSupportedProtocols(
 	return serverEntry.GetSupportedProtocols(
 		conditionallyEnabledComponents{},
 		conditionallyEnabledComponents{},
 		p.useUpstreamProxy,
 		p.useUpstreamProxy,
-		limitTunnelProtocols,
+		p.getLimitTunnelProtocols(connectTunnelCount),
 		p.limitTunnelDialPortNumbers,
 		p.limitTunnelDialPortNumbers,
 		p.limitQUICVersions,
 		p.limitQUICVersions,
 		excludeIntensive,
 		excludeIntensive,
@@ -1584,13 +1595,13 @@ func (p *protocolSelectionConstraints) selectProtocol(
 	connectTunnelCount int,
 	connectTunnelCount int,
 	excludeIntensive bool,
 	excludeIntensive bool,
 	excludeInproxy bool,
 	excludeInproxy bool,
-	serverEntry *protocol.ServerEntry) (string, bool) {
+	serverEntry *protocol.ServerEntry) (string, time.Duration, bool) {
 
 
 	candidateProtocols := p.supportedProtocols(
 	candidateProtocols := p.supportedProtocols(
 		connectTunnelCount, excludeIntensive, excludeInproxy, serverEntry)
 		connectTunnelCount, excludeIntensive, excludeInproxy, serverEntry)
 
 
 	if len(candidateProtocols) == 0 {
 	if len(candidateProtocols) == 0 {
-		return "", false
+		return "", 0, false
 	}
 	}
 
 
 	// Pick at random from the supported protocols. This ensures that we'll
 	// Pick at random from the supported protocols. This ensures that we'll
@@ -1599,9 +1610,62 @@ func (p *protocolSelectionConstraints) selectProtocol(
 	// through multi-capability servers, and a simpler ranked preference of
 	// through multi-capability servers, and a simpler ranked preference of
 	// protocols could lead to that protocol never being selected.
 	// protocols could lead to that protocol never being selected.
 
 
-	index := prng.Intn(len(candidateProtocols))
+	selectedProtocol := candidateProtocols[prng.Intn(len(candidateProtocols))]
+
+	if !protocol.TunnelProtocolUsesInproxy(selectedProtocol) ||
+		p.inproxyClientDialRateLimiter == nil {
+
+		return selectedProtocol, 0, true
+	}
+
+	// Rate limit in-proxy dials. This avoids triggering rate limits or
+	// similar errors from any intermediate CDN between the client and the
+	// broker. And avoids unnecessarily triggering the broker's
+	// application-level rate limiter, which will incur some overhead logging
+	// an event and returning a response.
+	//
+	// In personal pairing mode, or when protocol limits yield only in-proxy
+	// tunnel protocol candidates, no non-in-proxy protocol can be selected,
+	// so delay the dial. In other cases, skip the candidate and pick a
+	// non-in-proxy tunnel protocol.
+	//
+	// The delay is not applied here since the caller is holding the
+	// concurrentEstablishTunnelsMutex lock, potentially blocking other
+	// establishment workers. Instead the delay is returned and applied
+	// outside of the lock. This also allows for the delay to be reduced when
+	// the StaggerConnectionWorkers facility is active.
+
+	if p.isInproxyPersonalPairingMode ||
+		p.getLimitTunnelProtocols(connectTunnelCount).IsOnlyInproxyTunnelProtocols() {
+
+		r := p.inproxyClientDialRateLimiter.Reserve()
+		if !r.OK() {
+			NoticeInfo("in-proxy protocol selection rate limited: burst size exceeded")
+			return "", 0, false
+		}
+		delay := r.Delay()
+		if delay > 0 {
+			NoticeInfo("in-proxy protocol selection rate limited: %v", delay)
+		}
+		return selectedProtocol, delay, true
+
+	} else if !p.inproxyClientDialRateLimiter.Allow() {
+
+		NoticeInfo("in-proxy protocol selection skipped due to rate limit")
+
+		excludeInproxy = true
+
+		candidateProtocols = p.supportedProtocols(
+			connectTunnelCount, excludeIntensive, excludeInproxy, serverEntry)
+
+		if len(candidateProtocols) == 0 {
+			return "", 0, false
+		}
 
 
-	return candidateProtocols[index], true
+		return candidateProtocols[prng.Intn(len(candidateProtocols))], 0, true
+	}
+
+	return selectedProtocol, 0, true
 }
 }
 
 
 type candidateServerEntry struct {
 type candidateServerEntry struct {
@@ -1667,6 +1731,30 @@ func (controller *Controller) startEstablishing() {
 	// controller.serverAffinityDoneBroadcast.
 	// controller.serverAffinityDoneBroadcast.
 	controller.serverAffinityDoneBroadcast = make(chan struct{})
 	controller.serverAffinityDoneBroadcast = make(chan struct{})
 
 
+	// TODO: Add a buffer of 1 so we don't miss a signal while worker is
+	// starting? Trade-off is potential back-to-back fetches. As-is,
+	// establish will eventually signal another fetch.
+	controller.establishSignalForceTacticsFetch = make(chan struct{})
+
+	// Initialize the in-proxy client dial rate limiter. Rate limits are used in
+	// protocolSelectionConstraints.selectProtocol. When
+	// InproxyClientDialRateLimitQuantity is 0, there is no rate limit.
+	//
+	// The rate limiter is reset for each establishment, which ensures no
+	// delays carry over from a previous establishment run. However, this
+	// does mean that very frequent re-establishments may exceed the rate
+	// limit overall.
+
+	p := controller.config.GetParameters().Get()
+	inproxyRateLimitQuantity := p.Int(parameters.InproxyClientDialRateLimitQuantity)
+	inproxyRateLimitInterval := p.Duration(parameters.InproxyClientDialRateLimitInterval)
+	if inproxyRateLimitQuantity > 0 {
+		controller.inproxyClientDialRateLimiter = rate.NewLimiter(
+			rate.Limit(float64(inproxyRateLimitQuantity)/inproxyRateLimitInterval.Seconds()),
+			inproxyRateLimitQuantity)
+	}
+	p.Close()
+
 	controller.establishWaitGroup.Add(1)
 	controller.establishWaitGroup.Add(1)
 	go controller.launchEstablishing()
 	go controller.launchEstablishing()
 }
 }
@@ -1675,8 +1763,9 @@ func (controller *Controller) launchEstablishing() {
 
 
 	defer controller.establishWaitGroup.Done()
 	defer controller.establishWaitGroup.Done()
 
 
-	// Before starting the establish tunnel workers, get and apply
-	// tactics, launching a tactics request if required.
+	// Before starting the establish tunnel workers, get and apply tactics,
+	// launching a tactics request if required -- when there are no tactics,
+	// or the cached tactics have expired.
 	//
 	//
 	// Wait only TacticsWaitPeriod for the tactics request to complete (or
 	// Wait only TacticsWaitPeriod for the tactics request to complete (or
 	// fail) before proceeding with tunnel establishment, in case the tactics
 	// fail) before proceeding with tunnel establishment, in case the tactics
@@ -1691,25 +1780,75 @@ func (controller *Controller) launchEstablishing() {
 	//
 	//
 	// Any in-flight tactics request or pending retry will be
 	// Any in-flight tactics request or pending retry will be
 	// canceled when establishment is stopped.
 	// canceled when establishment is stopped.
+	//
+	// In some cases, no tunnel establishment can succeed without a fresh
+	// tactics fetch, even if there is existing, non-expired cached tactics.
+	// Currently, cases include in-proxy personal pairing mode and limiting
+	// tunnel protocols to in-proxy, where broker specs are both required and
+	// obtained exclusively from tactics. It is possible that cached tactics
+	// are found and used, but broker configurations have recently changed
+	// away from the broker specs in cached tactics.
+	//
+	// Another scenario, with exclusively in-proxy tunnel protocols, is a
+	// fresh start with no embedded server entries, where the initial
+	// GetTactics will fail with "no capable servers".
+	//
+	// To handle these cases, when cached tactics are used or no tactics can
+	// be fetched, the tactics worker goroutine will remain running and await
+	// a signal to force a tactics fetch that ignores any stored/cached
+	// tactics. Multiple signals and fetch attempts are supported, to retry
+	// when a GetTactics fetch iteration fails, including the "no capable
+	// servers" case, which may only succeed after a concurrent server list
+	// fetch completes.
+	//
+	// Limitation: this mechanism doesn't force repeated tactics fetches after
+	// one success, which risks being excessive. There's at most one
+	// successful fetch per establishment run. As such, it remains remotely
+	// possible that a tactics change, such as new broker specs, deployed in
+	// the middle of an establishment run, won't be fetched. A user-initiated
+	// stop/start toggle will work around this.
 
 
 	if !controller.config.DisableTactics {
 	if !controller.config.DisableTactics {
 
 
 		timeout := controller.config.GetParameters().Get().Duration(
 		timeout := controller.config.GetParameters().Get().Duration(
 			parameters.TacticsWaitPeriod)
 			parameters.TacticsWaitPeriod)
 
 
-		tacticsDone := make(chan struct{})
+		initialTacticsDone := make(chan struct{})
 		tacticsWaitPeriod := time.NewTimer(timeout)
 		tacticsWaitPeriod := time.NewTimer(timeout)
 		defer tacticsWaitPeriod.Stop()
 		defer tacticsWaitPeriod.Stop()
 
 
 		controller.establishWaitGroup.Add(1)
 		controller.establishWaitGroup.Add(1)
 		go func() {
 		go func() {
 			defer controller.establishWaitGroup.Done()
 			defer controller.establishWaitGroup.Done()
-			defer close(tacticsDone)
-			GetTactics(controller.establishCtx, controller.config)
+
+			useStoredTactics := true
+			fetched := GetTactics(
+				controller.establishCtx, controller.config, useStoredTactics)
+			close(initialTacticsDone)
+
+			if fetched {
+				return
+			}
+
+			for {
+				select {
+				case <-controller.establishCtx.Done():
+					return
+				case <-controller.establishSignalForceTacticsFetch:
+				}
+
+				useStoredTactics = false
+				fetched = GetTactics(
+					controller.establishCtx, controller.config, useStoredTactics)
+				if fetched {
+					// No more forced tactics fetches after the first success.
+					break
+				}
+			}
 		}()
 		}()
 
 
 		select {
 		select {
-		case <-tacticsDone:
+		case <-initialTacticsDone:
 		case <-tacticsWaitPeriod.C:
 		case <-tacticsWaitPeriod.C:
 		}
 		}
 
 
@@ -1741,6 +1880,9 @@ func (controller *Controller) launchEstablishing() {
 			p.TunnelProtocolPortLists(parameters.LimitTunnelDialPortNumbers)),
 			p.TunnelProtocolPortLists(parameters.LimitTunnelDialPortNumbers)),
 
 
 		replayCandidateCount: p.Int(parameters.ReplayCandidateCount),
 		replayCandidateCount: p.Int(parameters.ReplayCandidateCount),
+
+		isInproxyPersonalPairingMode: controller.config.IsInproxyPersonalPairingMode(),
+		inproxyClientDialRateLimiter: controller.inproxyClientDialRateLimiter,
 	}
 	}
 
 
 	// Adjust protocol limits for in-proxy personal proxy mode. In this mode,
 	// Adjust protocol limits for in-proxy personal proxy mode. In this mode,
@@ -1748,18 +1890,18 @@ func (controller *Controller) launchEstablishing() {
 	// corresponding personal compartment ID, so non-in-proxy tunnel
 	// corresponding personal compartment ID, so non-in-proxy tunnel
 	// protocols are disabled.
 	// protocols are disabled.
 
 
-	if len(controller.config.InproxyClientPersonalCompartmentIDs) > 0 {
+	if controller.config.IsInproxyPersonalPairingMode() {
 
 
 		if len(controller.protocolSelectionConstraints.initialLimitTunnelProtocols) > 0 {
 		if len(controller.protocolSelectionConstraints.initialLimitTunnelProtocols) > 0 {
 			controller.protocolSelectionConstraints.initialLimitTunnelProtocols =
 			controller.protocolSelectionConstraints.initialLimitTunnelProtocols =
 				controller.protocolSelectionConstraints.
 				controller.protocolSelectionConstraints.
-					initialLimitTunnelProtocols.OnlyInproxyTunnelProtocols()
+					initialLimitTunnelProtocols.PruneNonInproxyTunnelProtocols()
 		}
 		}
 
 
 		if len(controller.protocolSelectionConstraints.limitTunnelProtocols) > 0 {
 		if len(controller.protocolSelectionConstraints.limitTunnelProtocols) > 0 {
 			controller.protocolSelectionConstraints.limitTunnelProtocols =
 			controller.protocolSelectionConstraints.limitTunnelProtocols =
 				controller.protocolSelectionConstraints.
 				controller.protocolSelectionConstraints.
-					limitTunnelProtocols.OnlyInproxyTunnelProtocols()
+					limitTunnelProtocols.PruneNonInproxyTunnelProtocols()
 		}
 		}
 
 
 		// This covers two cases: if there was no limitTunnelProtocols to
 		// This covers two cases: if there was no limitTunnelProtocols to
@@ -1773,8 +1915,17 @@ func (controller *Controller) launchEstablishing() {
 	}
 	}
 
 
 	// ConnectionWorkerPoolSize may be set by tactics.
 	// ConnectionWorkerPoolSize may be set by tactics.
+	//
+	// In-proxy personal pairing mode uses a distinct parameter which is
+	// typically configured to a lower number, limiting concurrent load and
+	// announcement consumption for personal proxies.
 
 
-	workerPoolSize := p.Int(parameters.ConnectionWorkerPoolSize)
+	var workerPoolSize int
+	if controller.config.IsInproxyPersonalPairingMode() {
+		workerPoolSize = p.Int(parameters.InproxyPersonalPairingConnectionWorkerPoolSize)
+	} else {
+		workerPoolSize = p.Int(parameters.ConnectionWorkerPoolSize)
+	}
 
 
 	// When TargetServerEntry is used, override any worker pool size config or
 	// When TargetServerEntry is used, override any worker pool size config or
 	// tactic parameter and use a pool size of 1. The typical use case for
 	// tactic parameter and use a pool size of 1. The typical use case for
@@ -1950,6 +2101,8 @@ func (controller *Controller) stopEstablishing() {
 	controller.establishWaitGroup = nil
 	controller.establishWaitGroup = nil
 	controller.candidateServerEntries = nil
 	controller.candidateServerEntries = nil
 	controller.serverAffinityDoneBroadcast = nil
 	controller.serverAffinityDoneBroadcast = nil
+	controller.establishSignalForceTacticsFetch = nil
+	controller.inproxyClientDialRateLimiter = nil
 
 
 	controller.concurrentEstablishTunnelsMutex.Lock()
 	controller.concurrentEstablishTunnelsMutex.Lock()
 	peakConcurrent := controller.peakConcurrentEstablishTunnels
 	peakConcurrent := controller.peakConcurrentEstablishTunnels
@@ -2141,11 +2294,56 @@ loop:
 		// No fetches are triggered when TargetServerEntry is specified. In that
 		// No fetches are triggered when TargetServerEntry is specified. In that
 		// case, we're only trying to connect to a specific server entry.
 		// case, we're only trying to connect to a specific server entry.
 
 
-		if (candidateServerEntryCount == 0 ||
-			time.Since(controller.establishStartTime)-totalNetworkWaitDuration > workTime) &&
-			controller.config.TargetServerEntry == "" {
+		if candidateServerEntryCount == 0 ||
+			time.Since(controller.establishStartTime)-totalNetworkWaitDuration > workTime {
 
 
-			controller.triggerFetches()
+			if controller.config.TargetServerEntry == "" {
+				controller.triggerFetches()
+			}
+
+			// Trigger a forced tactics fetch. Currently, this is done only
+			// for cases where in-proxy tunnel protocols must be selected.
+			// When there were no server entries, wait until a server entry
+			// fetch has completed.
+
+			// Lock required to access controller.establishConnectTunnelCount.
+			controller.concurrentEstablishTunnelsMutex.Lock()
+			limitInproxyOnly := controller.protocolSelectionConstraints.getLimitTunnelProtocols(
+				controller.establishConnectTunnelCount).IsOnlyInproxyTunnelProtocols()
+			controller.concurrentEstablishTunnelsMutex.Unlock()
+
+			if limitInproxyOnly || controller.config.IsInproxyPersonalPairingMode() {
+
+				// Simply sleep and poll for any imported server entries;
+				// perform one sleep after HasServerEntries, in order to give
+				// the import some extra time. Limitation: if the sleep loop
+				// ends too soon, the tactics fetch won't find a
+				// tactics-capable server entry; in this case, workTime must
+				// elapse before another tactics fetch is triggered.
+				//
+				// TODO: synchronize with server list fetch/import complete;
+				// or use ScanServerEntries (but see function comment about
+				// performance concern) to check for at least one
+				// tactics-capable server entry.
+
+				if candidateServerEntryCount == 0 {
+					stopWaiting := false
+					for {
+						if HasServerEntries() {
+							stopWaiting = true
+						}
+						common.SleepWithContext(controller.establishCtx, 1*time.Second)
+						if stopWaiting || controller.establishCtx.Err() != nil {
+							break
+						}
+					}
+				}
+
+				select {
+				case controller.establishSignalForceTacticsFetch <- struct{}{}:
+				default:
+				}
+			}
 		}
 		}
 
 
 		// After a complete iteration of candidate servers, pause before iterating again.
 		// After a complete iteration of candidate servers, pause before iterating again.
@@ -2297,20 +2495,32 @@ loop:
 				replayProtocol)
 				replayProtocol)
 		}
 		}
 
 
+		// The dial rate limit delay, determined by protocolSelectionConstraints.selectProtocol, is
+		// not applied within that function since this worker holds the concurrentEstablishTunnelsMutex
+		// lock when that's called. Instead, the required delay is passed out and applied below.
+		// It's safe for the selectProtocol callback to write to dialRateLimitDelay without
+		// synchronization since this worker goroutine invokes the callback.
+
+		var dialRateLimitDelay time.Duration
+
 		selectProtocol := func(serverEntry *protocol.ServerEntry) (string, bool) {
 		selectProtocol := func(serverEntry *protocol.ServerEntry) (string, bool) {
 
 
 			// The in-proxy protocol selection probability allows for
 			// The in-proxy protocol selection probability allows for
 			// tuning/limiting in-proxy usage independent of
 			// tuning/limiting in-proxy usage independent of
 			// LimitTunnelProtocol targeting.
 			// LimitTunnelProtocol targeting.
 
 
-			onlyInproxy := len(controller.config.InproxyClientPersonalCompartmentIDs) > 0
+			onlyInproxy := controller.config.IsInproxyPersonalPairingMode()
 			includeInproxy := onlyInproxy || prng.FlipWeightedCoin(inproxySelectionProbability)
 			includeInproxy := onlyInproxy || prng.FlipWeightedCoin(inproxySelectionProbability)
 
 
-			return controller.protocolSelectionConstraints.selectProtocol(
+			selectedProtocol, rateLimitDelay, ok := controller.protocolSelectionConstraints.selectProtocol(
 				controller.establishConnectTunnelCount,
 				controller.establishConnectTunnelCount,
 				excludeIntensive,
 				excludeIntensive,
 				!includeInproxy,
 				!includeInproxy,
 				serverEntry)
 				serverEntry)
+
+			dialRateLimitDelay = rateLimitDelay
+
+			return selectedProtocol, ok
 		}
 		}
 
 
 		// MakeDialParameters may return a replay instance, if the server
 		// MakeDialParameters may return a replay instance, if the server
@@ -2390,6 +2600,8 @@ loop:
 
 
 		controller.concurrentEstablishTunnelsMutex.Unlock()
 		controller.concurrentEstablishTunnelsMutex.Unlock()
 
 
+		startStagger := time.Now()
+
 		// Apply stagger only now that we're past MakeDialParameters and
 		// Apply stagger only now that we're past MakeDialParameters and
 		// protocol selection logic which may have caused the candidate to be
 		// protocol selection logic which may have caused the candidate to be
 		// skipped. The stagger logic delays dialing, and we don't want to
 		// skipped. The stagger logic delays dialing, and we don't want to
@@ -2412,6 +2624,15 @@ loop:
 			controller.staggerMutex.Unlock()
 			controller.staggerMutex.Unlock()
 		}
 		}
 
 
+		// Apply any dial rate limit delay now, after unlocking
+		// concurrentEstablishTunnelsMutex. The delay may be reduced by the
+		// time spent waiting to stagger.
+
+		dialRateLimitDelay -= time.Since(startStagger)
+		if dialRateLimitDelay > 0 {
+			common.SleepWithContext(controller.establishCtx, dialRateLimitDelay)
+		}
+
 		// ConnectTunnel will allocate significant memory, so first attempt to
 		// ConnectTunnel will allocate significant memory, so first attempt to
 		// reclaim as much as possible.
 		// reclaim as much as possible.
 		DoGarbageCollection()
 		DoGarbageCollection()
@@ -2510,7 +2731,7 @@ func (controller *Controller) runInproxyProxy() {
 			// When not running client tunnel establishment, perform an OOB tactics
 			// When not running client tunnel establishment, perform an OOB tactics
 			// fetch, if required, here.
 			// fetch, if required, here.
 
 
-			GetTactics(controller.runCtx, controller.config)
+			GetTactics(controller.runCtx, controller.config, true)
 
 
 		} else if !controller.config.InproxySkipAwaitFullyConnected {
 		} else if !controller.config.InproxySkipAwaitFullyConnected {
 
 
@@ -2849,8 +3070,7 @@ func (controller *Controller) inproxyHandleProxyTacticsPayload(
 		return false
 		return false
 	}
 	}
 
 
-	if tacticsRecord != nil &&
-		prng.FlipWeightedCoin(tacticsRecord.Tactics.Probability) {
+	if tacticsRecord != nil {
 
 
 		// SetParameters signals registered components, including broker
 		// SetParameters signals registered components, including broker
 		// client and NAT state managers, that must reset upon tactics changes.
 		// client and NAT state managers, that must reset upon tactics changes.

+ 21 - 4
psiphon/controller_test.go

@@ -39,6 +39,7 @@ import (
 
 
 	socks "github.com/Psiphon-Labs/goptlib"
 	socks "github.com/Psiphon-Labs/goptlib"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/inproxy"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/quic"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/quic"
 	"github.com/elazarl/goproxy"
 	"github.com/elazarl/goproxy"
@@ -300,29 +301,37 @@ func TestFrontedQUIC(t *testing.T) {
 
 
 func TestInproxyOSSH(t *testing.T) {
 func TestInproxyOSSH(t *testing.T) {
 
 
-	t.Skipf("temporarily disabled")
+	if !inproxy.Enabled() {
+		t.Skip("In-proxy is not enabled")
+	}
 
 
 	controllerRun(t,
 	controllerRun(t,
 		&controllerRunConfig{
 		&controllerRunConfig{
 			protocol:                 "INPROXY-WEBRTC-OSSH",
 			protocol:                 "INPROXY-WEBRTC-OSSH",
 			disableUntunneledUpgrade: true,
 			disableUntunneledUpgrade: true,
+			useInproxyDialRateLimit:  true,
 		})
 		})
 }
 }
 
 
 func TestInproxyQUICOSSH(t *testing.T) {
 func TestInproxyQUICOSSH(t *testing.T) {
 
 
-	t.Skipf("temporarily disabled")
+	if !inproxy.Enabled() {
+		t.Skip("In-proxy is not enabled")
+	}
 
 
 	controllerRun(t,
 	controllerRun(t,
 		&controllerRunConfig{
 		&controllerRunConfig{
 			protocol:                 "INPROXY-WEBRTC-QUIC-OSSH",
 			protocol:                 "INPROXY-WEBRTC-QUIC-OSSH",
 			disableUntunneledUpgrade: true,
 			disableUntunneledUpgrade: true,
+			useInproxyDialRateLimit:  true,
 		})
 		})
 }
 }
 
 
 func TestInproxyUnfrontedMeekHTTPS(t *testing.T) {
 func TestInproxyUnfrontedMeekHTTPS(t *testing.T) {
 
 
-	t.Skipf("temporarily disabled")
+	if !inproxy.Enabled() {
+		t.Skip("In-proxy is not enabled")
+	}
 
 
 	controllerRun(t,
 	controllerRun(t,
 		&controllerRunConfig{
 		&controllerRunConfig{
@@ -333,7 +342,9 @@ func TestInproxyUnfrontedMeekHTTPS(t *testing.T) {
 
 
 func TestInproxyTLSOSSH(t *testing.T) {
 func TestInproxyTLSOSSH(t *testing.T) {
 
 
-	t.Skipf("temporarily disabled")
+	if !inproxy.Enabled() {
+		t.Skip("In-proxy is not enabled")
+	}
 
 
 	controllerRun(t,
 	controllerRun(t,
 		&controllerRunConfig{
 		&controllerRunConfig{
@@ -372,6 +383,7 @@ type controllerRunConfig struct {
 	transformHostNames       bool
 	transformHostNames       bool
 	useFragmentor            bool
 	useFragmentor            bool
 	useLegacyAPIEncoding     bool
 	useLegacyAPIEncoding     bool
+	useInproxyDialRateLimit  bool
 }
 }
 
 
 func controllerRun(t *testing.T, runConfig *controllerRunConfig) {
 func controllerRun(t *testing.T, runConfig *controllerRunConfig) {
@@ -439,6 +451,11 @@ func controllerRun(t *testing.T, runConfig *controllerRunConfig) {
 		modifyConfig["TargetAPIEncoding"] = protocol.PSIPHON_API_ENCODING_JSON
 		modifyConfig["TargetAPIEncoding"] = protocol.PSIPHON_API_ENCODING_JSON
 	}
 	}
 
 
+	if runConfig.useInproxyDialRateLimit {
+		modifyConfig["InproxyClientDialRateLimitQuantity"] = 2
+		modifyConfig["InproxyClientDialRateLimitIntervalMilliseconds"] = 1000
+	}
+
 	configJSON, _ = json.Marshal(modifyConfig)
 	configJSON, _ = json.Marshal(modifyConfig)
 
 
 	config, err := LoadConfig(configJSON)
 	config, err := LoadConfig(configJSON)

+ 1 - 1
psiphon/dataStore.go

@@ -1302,7 +1302,7 @@ func deleteServerEntryHelper(
 //
 //
 // ScanServerEntries may be slow to execute, particularly for older devices
 // ScanServerEntries may be slow to execute, particularly for older devices
 // and/or very large server lists. Callers should avoid blocking on
 // and/or very large server lists. Callers should avoid blocking on
-// ScanServerEntries where possible; and use the canel option to interrupt
+// ScanServerEntries where possible; and use the cancel option to interrupt
 // scans that are no longer required.
 // scans that are no longer required.
 func ScanServerEntries(callback func(*protocol.ServerEntry) bool) error {
 func ScanServerEntries(callback func(*protocol.ServerEntry) bool) error {
 
 

+ 2 - 1
psiphon/dialParameters_test.go

@@ -851,7 +851,8 @@ func TestLimitTunnelDialPortNumbers(t *testing.T) {
 	}
 	}
 
 
 	selectProtocol := func(serverEntry *protocol.ServerEntry) (string, bool) {
 	selectProtocol := func(serverEntry *protocol.ServerEntry) (string, bool) {
-		return constraints.selectProtocol(0, false, false, serverEntry)
+		protocol, _, ok := constraints.selectProtocol(0, false, false, serverEntry)
+		return protocol, ok
 	}
 	}
 
 
 	for _, tunnelProtocol := range protocol.SupportedTunnelProtocols {
 	for _, tunnelProtocol := range protocol.SupportedTunnelProtocols {

+ 1 - 1
psiphon/feedback.go

@@ -142,7 +142,7 @@ func SendFeedback(ctx context.Context, config *Config, diagnostics, uploadPath s
 	//   or a network ID of "VPN" if some other non-Psiphon VPN is running
 	//   or a network ID of "VPN" if some other non-Psiphon VPN is running
 	//   (the caller should ensure a network ID of "VPN" in this case).
 	//   (the caller should ensure a network ID of "VPN" in this case).
 
 
-	GetTactics(getTacticsCtx, config)
+	GetTactics(getTacticsCtx, config, true)
 
 
 	// Get the latest client parameters
 	// Get the latest client parameters
 	p = config.GetParameters().Get()
 	p = config.GetParameters().Get()

+ 29 - 6
psiphon/inproxy.go

@@ -247,16 +247,39 @@ func NewInproxyBrokerClientInstance(
 		return nil, errors.Trace(err)
 		return nil, errors.Trace(err)
 	}
 	}
 
 
-	// Select the broker to use, optionally favoring brokers with replay
-	// data.
+	// Select the broker to use, optionally favoring brokers with replay data.
+	// In the InproxyBrokerSpecs calls, the first non-empty tactics parameter
+	// list is used.
+	//
+	// Optional broker specs may be used to specify broker(s) dedicated to
+	// personal pairing, a configuration which can be used to reserve more
+	// capacity for personal pairing, given the simple rendezvous scheme below.
 
 
 	var brokerSpecs parameters.InproxyBrokerSpecsValue
 	var brokerSpecs parameters.InproxyBrokerSpecsValue
 	if isProxy {
 	if isProxy {
-		brokerSpecs = p.InproxyBrokerSpecs(
-			parameters.InproxyProxyBrokerSpecs, parameters.InproxyBrokerSpecs)
+		if config.IsInproxyPersonalPairingMode() {
+			brokerSpecs = p.InproxyBrokerSpecs(
+				parameters.InproxyProxyPersonalPairingBrokerSpecs,
+				parameters.InproxyPersonalPairingBrokerSpecs,
+				parameters.InproxyProxyBrokerSpecs,
+				parameters.InproxyBrokerSpecs)
+		} else {
+			brokerSpecs = p.InproxyBrokerSpecs(
+				parameters.InproxyProxyBrokerSpecs,
+				parameters.InproxyBrokerSpecs)
+		}
 	} else {
 	} else {
-		brokerSpecs = p.InproxyBrokerSpecs(
-			parameters.InproxyClientBrokerSpecs, parameters.InproxyBrokerSpecs)
+		if config.IsInproxyPersonalPairingMode() {
+			brokerSpecs = p.InproxyBrokerSpecs(
+				parameters.InproxyClientPersonalPairingBrokerSpecs,
+				parameters.InproxyPersonalPairingBrokerSpecs,
+				parameters.InproxyClientBrokerSpecs,
+				parameters.InproxyBrokerSpecs)
+		} else {
+			brokerSpecs = p.InproxyBrokerSpecs(
+				parameters.InproxyClientBrokerSpecs,
+				parameters.InproxyBrokerSpecs)
+		}
 	}
 	}
 	if len(brokerSpecs) == 0 {
 	if len(brokerSpecs) == 0 {
 		return nil, errors.TraceNew("no broker specs")
 		return nil, errors.TraceNew("no broker specs")

+ 1 - 2
psiphon/server/config.go

@@ -1129,8 +1129,7 @@ func GenerateConfig(params *GenerateConfigParams) ([]byte, []byte, []byte, []byt
 			RequestPrivateKey:    decodedTacticsRequestPrivateKey,
 			RequestPrivateKey:    decodedTacticsRequestPrivateKey,
 			RequestObfuscatedKey: decodedTacticsRequestObfuscatedKey,
 			RequestObfuscatedKey: decodedTacticsRequestObfuscatedKey,
 			DefaultTactics: tactics.Tactics{
 			DefaultTactics: tactics.Tactics{
-				TTL:         "1m",
-				Probability: 1.0,
+				TTL: "1m",
 			},
 			},
 		}
 		}
 
 

+ 9 - 2
psiphon/serverApi.go

@@ -403,8 +403,7 @@ func (serverContext *ServerContext) doHandshakeRequest(ignoreStatsRegexps bool)
 				return errors.Trace(err)
 				return errors.Trace(err)
 			}
 			}
 
 
-			if tacticsRecord != nil &&
-				prng.FlipWeightedCoin(tacticsRecord.Tactics.Probability) {
+			if tacticsRecord != nil {
 
 
 				err := serverContext.tunnel.config.SetParameters(
 				err := serverContext.tunnel.config.SetParameters(
 					tacticsRecord.Tag, true, tacticsRecord.Tactics.Parameters)
 					tacticsRecord.Tag, true, tacticsRecord.Tactics.Parameters)
@@ -1025,6 +1024,14 @@ func getBaseAPIParameters(
 
 
 	params := make(common.APIParameters)
 	params := make(common.APIParameters)
 
 
+	// Temporary measure: unconditionally include legacy session_id and
+	// client_session_id fields for compatibility with existing servers used
+	// in CI.
+	//
+	// TODO: remove once necessary servers are upgraded
+	params["session_id"] = config.SessionID
+	params["client_session_id"] = config.SessionID
+
 	if includeSessionID {
 	if includeSessionID {
 		// The session ID is included in non-SSH API requests only. For SSH
 		// The session ID is included in non-SSH API requests only. For SSH
 		// API requests, the Psiphon server already has the client's session ID.
 		// API requests, the Psiphon server already has the client's session ID.

+ 33 - 12
psiphon/tactics.go

@@ -49,7 +49,15 @@ import (
 // and without blocking the Controller from starting. Accessing tactics is
 // and without blocking the Controller from starting. Accessing tactics is
 // most critical for untunneled network operations; when a Controller is
 // most critical for untunneled network operations; when a Controller is
 // running, a tunnel may be used. See TacticsStorer for more details.
 // running, a tunnel may be used. See TacticsStorer for more details.
-func GetTactics(ctx context.Context, config *Config) {
+//
+// When the useStoredTactics input flag is false, any locally cached tactics
+// are ignored, regardless of TTL, and a fetch is always performed. GetTactics
+// returns true when a fetch was performed and false otherwise (either cached
+// tactics were found and applied, or there was a failure). This combination
+// of useStoredTactics input and fetchedTactics output is used by the
+// caller to force a fetch if one was not already performed to handle states
+// where no tunnels can be established due to missing tactics.
+func GetTactics(ctx context.Context, config *Config, useStoredTactics bool) (fetchedTactics bool) {
 
 
 	// Limitation: GetNetworkID may not account for device VPN status, so
 	// Limitation: GetNetworkID may not account for device VPN status, so
 	// Psiphon-over-Psiphon or Psiphon-over-other-VPN scenarios can encounter
 	// Psiphon-over-Psiphon or Psiphon-over-other-VPN scenarios can encounter
@@ -61,16 +69,21 @@ func GetTactics(ctx context.Context, config *Config) {
 	//    network ID remains the same. Initial applied tactics will be for the
 	//    network ID remains the same. Initial applied tactics will be for the
 	//    remote egress region/ISP, not the local region/ISP.
 	//    remote egress region/ISP, not the local region/ISP.
 
 
-	tacticsRecord, err := tactics.UseStoredTactics(
-		GetTacticsStorer(config),
-		config.GetNetworkID())
-	if err != nil {
-		NoticeWarning("get stored tactics failed: %s", err)
+	var tacticsRecord *tactics.Record
+
+	if useStoredTactics {
+		var err error
+		tacticsRecord, err = tactics.UseStoredTactics(
+			GetTacticsStorer(config),
+			config.GetNetworkID())
+		if err != nil {
+			NoticeWarning("get stored tactics failed: %s", err)
 
 
-		// The error will be due to a local datastore problem.
-		// While we could proceed with the tactics request, this
-		// could result in constant tactics requests. So, abort.
-		return
+			// The error will be due to a local datastore problem.
+			// While we could proceed with the tactics request, this
+			// could result in constant tactics requests. So, abort.
+			return
+		}
 	}
 	}
 
 
 	if tacticsRecord == nil {
 	if tacticsRecord == nil {
@@ -125,6 +138,13 @@ func GetTactics(ctx context.Context, config *Config) {
 
 
 			if err == nil {
 			if err == nil {
 				if tacticsRecord != nil {
 				if tacticsRecord != nil {
+
+					// Set the return value indicating a successful fetch.
+					// Note that applying the tactics below may still fail,
+					// but this is not an expected case and we don't want the
+					// caller to continuously force refetches after this point.
+					fetchedTactics = true
+
 					// The fetch succeeded, so exit the fetch loop and apply
 					// The fetch succeeded, so exit the fetch loop and apply
 					// the result.
 					// the result.
 					break
 					break
@@ -163,8 +183,7 @@ func GetTactics(ctx context.Context, config *Config) {
 		}
 		}
 	}
 	}
 
 
-	if tacticsRecord != nil &&
-		prng.FlipWeightedCoin(tacticsRecord.Tactics.Probability) {
+	if tacticsRecord != nil {
 
 
 		err := config.SetParameters(
 		err := config.SetParameters(
 			tacticsRecord.Tag, true, tacticsRecord.Tactics.Parameters)
 			tacticsRecord.Tag, true, tacticsRecord.Tactics.Parameters)
@@ -184,6 +203,8 @@ func GetTactics(ctx context.Context, config *Config) {
 	// to be proceeding to the memory-intensive tunnel establishment phase.
 	// to be proceeding to the memory-intensive tunnel establishment phase.
 	DoGarbageCollection()
 	DoGarbageCollection()
 	emitMemoryMetrics()
 	emitMemoryMetrics()
+
+	return
 }
 }
 
 
 // fetchTactics performs a tactics request using the specified server entry.
 // fetchTactics performs a tactics request using the specified server entry.

+ 1 - 1
psiphon/tactics_test.go

@@ -117,7 +117,7 @@ func TestStandAloneGetTactics(t *testing.T) {
 	// operations in GetTactics.
 	// operations in GetTactics.
 	CloseDataStore()
 	CloseDataStore()
 
 
-	GetTactics(ctx, config)
+	GetTactics(ctx, config, true)
 
 
 	if atomic.LoadInt32(&gotTactics) != 1 {
 	if atomic.LoadInt32(&gotTactics) != 1 {
 		t.Fatalf("failed to get tactics")
 		t.Fatalf("failed to get tactics")