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

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"
 	InproxyAllBrokerPublicKeys                         = "InproxyAllBrokerPublicKeys"
 	InproxyBrokerSpecs                                 = "InproxyBrokerSpecs"
+	InproxyPersonalPairingBrokerSpecs                  = "InproxyPersonalPairingBrokerSpecs"
 	InproxyProxyBrokerSpecs                            = "InproxyProxyBrokerSpecs"
+	InproxyProxyPersonalPairingBrokerSpecs             = "InproxyProxyPersonalPairingBrokerSpecs"
 	InproxyClientBrokerSpecs                           = "InproxyClientBrokerSpecs"
+	InproxyClientPersonalPairingBrokerSpecs            = "InproxyClientPersonalPairingBrokerSpecs"
 	InproxyReplayBrokerDialParametersTTL               = "InproxyReplayBrokerDialParametersTTL"
 	InproxyReplayBrokerUpdateFrequency                 = "InproxyReplayBrokerUpdateFrequency"
 	InproxyReplayBrokerDialParametersProbability       = "InproxyReplayBrokerDialParametersProbability"
@@ -434,6 +437,9 @@ const (
 	InproxyProxyDestinationDialTimeout                 = "InproxyProxyDestinationDialTimeout"
 	InproxyPsiphonAPIRequestTimeout                    = "InproxyPsiphonAPIRequestTimeout"
 	InproxyProxyTotalActivityNoticePeriod              = "InproxyProxyTotalActivityNoticePeriod"
+	InproxyPersonalPairingConnectionWorkerPoolSize     = "InproxyPersonalPairingConnectionWorkerPoolSize"
+	InproxyClientDialRateLimitQuantity                 = "InproxyClientDialRateLimitQuantity"
+	InproxyClientDialRateLimitInterval                 = "InproxyClientDialRateLimitInterval"
 
 	// Retired parameters
 
@@ -867,8 +873,11 @@ var defaultParameters = map[string]struct {
 	InproxyTunnelProtocolSelectionProbability:          {value: 0.5, minimum: 0.0},
 	InproxyAllBrokerPublicKeys:                         {value: []string{}, flags: serverSideOnly},
 	InproxyBrokerSpecs:                                 {value: InproxyBrokerSpecsValue{}},
+	InproxyPersonalPairingBrokerSpecs:                  {value: InproxyBrokerSpecsValue{}},
 	InproxyProxyBrokerSpecs:                            {value: InproxyBrokerSpecsValue{}},
+	InproxyProxyPersonalPairingBrokerSpecs:             {value: InproxyBrokerSpecsValue{}},
 	InproxyClientBrokerSpecs:                           {value: InproxyBrokerSpecsValue{}},
+	InproxyClientPersonalPairingBrokerSpecs:            {value: InproxyBrokerSpecsValue{}},
 	InproxyReplayBrokerDialParametersTTL:               {value: 24 * time.Hour, minimum: time.Duration(0)},
 	InproxyReplayBrokerUpdateFrequency:                 {value: 5 * time.Minute, minimum: time.Duration(0)},
 	InproxyReplayBrokerDialParametersProbability:       {value: 1.0, minimum: 0.0},
@@ -882,7 +891,7 @@ var defaultParameters = map[string]struct {
 	InproxyBrokerMatcherAnnouncementNonlimitedProxyIDs: {value: []string{}, flags: serverSideOnly},
 	InproxyBrokerMatcherOfferLimitEntryCount:           {value: 10, 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},
 	InproxyBrokerClientOfferTimeout:                    {value: 10 * 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},
 	InproxyPsiphonAPIRequestTimeout:                    {value: 10 * time.Second, minimum: 1 * time.Second, flags: useNetworkLatencyMultiplier},
 	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

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

@@ -146,7 +146,7 @@ func (t TunnelProtocols) PruneInvalid() TunnelProtocols {
 	return u
 }
 
-func (t TunnelProtocols) OnlyInproxyTunnelProtocols() TunnelProtocols {
+func (t TunnelProtocols) PruneNonInproxyTunnelProtocols() TunnelProtocols {
 	u := make(TunnelProtocols, 0)
 	for _, p := range t {
 		if TunnelProtocolUsesInproxy(p) {
@@ -156,6 +156,15 @@ func (t TunnelProtocols) OnlyInproxyTunnelProtocols() TunnelProtocols {
 	return u
 }
 
+func (t TunnelProtocols) IsOnlyInproxyTunnelProtocols() bool {
+	for _, p := range t {
+		if !TunnelProtocolUsesInproxy(p) {
+			return false
+		}
+	}
+	return true
+}
+
 type LabeledTunnelProtocols map[string]TunnelProtocols
 
 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
 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
 timeouts.
 
@@ -217,8 +211,8 @@ var (
 // matching filter are merged into the client tactics.
 //
 // 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 {
 	common.ReloadableFile
 
@@ -232,7 +226,7 @@ type Server struct {
 	RequestObfuscatedKey []byte
 
 	// DefaultTactics is the baseline tactics for all clients. It must include a
-	// TTL and Probability.
+	// TTL.
 	DefaultTactics Tactics
 
 	// 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.
 	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
 	// be a subset of parameter.ClientParameter values and follow
 	// the corresponding data type and minimum value constraints.
@@ -540,13 +530,6 @@ func (server *Server) Validate() error {
 			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)
 		if err != nil {
 			return errors.Trace(err)
@@ -1079,8 +1062,7 @@ func medianSampleRTTMilliseconds(samples []SpeedTestSample) int {
 func (t *Tactics) clone(includeServerSideOnly bool) *Tactics {
 
 	u := &Tactics{
-		TTL:         t.TTL,
-		Probability: t.Probability,
+		TTL: t.TTL,
 	}
 
 	// 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
 	}
 
-	if u.Probability != 0.0 {
-		t.Probability = u.Probability
-	}
-
 	// Note: there is no deep copy of parameter values; the the returned
 	// Tactics shares memory with the original and its individual parameters
 	// should not be modified.
@@ -1744,9 +1722,6 @@ func applyTacticsPayload(
 	if ttl <= 0 {
 		return newTactics, errors.TraceNew("invalid TTL")
 	}
-	if record.Tactics.Probability <= 0.0 {
-		return newTactics, errors.TraceNew("invalid probability")
-	}
 
 	// Set or extend the expiry.
 

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

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

+ 67 - 29
psiphon/config.go

@@ -226,7 +226,7 @@ type Config struct {
 	EstablishTunnelServerAffinityGracePeriodMilliseconds *int
 
 	// 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.
 	ConnectionWorkerPoolSize int
 
@@ -665,38 +665,28 @@ type Config struct {
 	//
 	// 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
 
+	// 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
 	// showing proxy connection information and bytes transferred.
 	EmitInproxyProxyActivity bool
@@ -999,8 +989,11 @@ type Config struct {
 	InproxyAllowClient                                     *bool
 	InproxyTunnelProtocolSelectionProbability              *float64
 	InproxyBrokerSpecs                                     parameters.InproxyBrokerSpecsValue
-	InproxyClientBrokerSpecs                               parameters.InproxyBrokerSpecsValue
+	InproxyPersonalPairingBrokerSpecs                      parameters.InproxyBrokerSpecsValue
 	InproxyProxyBrokerSpecs                                parameters.InproxyBrokerSpecsValue
+	InproxyProxyPersonalPairingBrokerSpecs                 parameters.InproxyBrokerSpecsValue
+	InproxyClientBrokerSpecs                               parameters.InproxyBrokerSpecsValue
+	InproxyClientPersonalPairingBrokerSpecs                parameters.InproxyBrokerSpecsValue
 	InproxyReplayBrokerDialParametersTTLSeconds            *int
 	InproxyReplayBrokerUpdateFrequencySeconds              *int
 	InproxyReplayBrokerDialParametersProbability           *float64
@@ -1045,6 +1038,8 @@ type Config struct {
 	InproxyProxyDestinationDialTimeoutMilliseconds         *int
 	InproxyPsiphonAPIRequestTimeoutMilliseconds            *int
 	InproxyProxyTotalActivityNoticePeriodMilliseconds      *int
+	InproxyClientDialRateLimitQuantity                     *int
+	InproxyClientDialRateLimitIntervalMilliseconds         *int
 
 	InproxySkipAwaitFullyConnected  bool
 	InproxyEnableWebRTCDebugLogging bool
@@ -1787,6 +1782,13 @@ func (config *Config) GetNetworkID() string {
 	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{} {
 
 	// 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
 	}
 
+	if config.InproxyPersonalPairingConnectionWorkerPoolSize != 0 {
+		applyParameters[parameters.InproxyPersonalPairingConnectionWorkerPoolSize] = config.InproxyPersonalPairingConnectionWorkerPoolSize
+	}
+
 	if config.InproxyAllowProxy != nil {
 		applyParameters[parameters.InproxyAllowProxy] = *config.InproxyAllowProxy
 	}
@@ -2392,14 +2398,26 @@ func (config *Config) makeConfigParameters() map[string]interface{} {
 		applyParameters[parameters.InproxyBrokerSpecs] = config.InproxyBrokerSpecs
 	}
 
+	if len(config.InproxyPersonalPairingBrokerSpecs) > 0 {
+		applyParameters[parameters.InproxyPersonalPairingBrokerSpecs] = config.InproxyPersonalPairingBrokerSpecs
+	}
+
 	if len(config.InproxyProxyBrokerSpecs) > 0 {
 		applyParameters[parameters.InproxyProxyBrokerSpecs] = config.InproxyProxyBrokerSpecs
 	}
 
+	if len(config.InproxyProxyPersonalPairingBrokerSpecs) > 0 {
+		applyParameters[parameters.InproxyProxyPersonalPairingBrokerSpecs] = config.InproxyProxyPersonalPairingBrokerSpecs
+	}
+
 	if len(config.InproxyClientBrokerSpecs) > 0 {
 		applyParameters[parameters.InproxyClientBrokerSpecs] = config.InproxyClientBrokerSpecs
 	}
 
+	if len(config.InproxyClientPersonalPairingBrokerSpecs) > 0 {
+		applyParameters[parameters.InproxyClientPersonalPairingBrokerSpecs] = config.InproxyClientPersonalPairingBrokerSpecs
+	}
+
 	if config.InproxyReplayBrokerDialParametersTTLSeconds != nil {
 		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)
 	}
 
+	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
 	// update setDialParametersHash.
 
@@ -3184,14 +3210,26 @@ func (config *Config) setDialParametersHash() {
 		hash.Write([]byte("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 {
 		hash.Write([]byte("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 {
 		hash.Write([]byte("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 {
 		hash.Write([]byte("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/tun"
 	lrucache "github.com/cognusion/go-cache-lru"
+	"golang.org/x/time/rate"
 )
 
 // Controller is a tunnel lifecycle coordinator. It manages lists of servers to
@@ -96,6 +97,8 @@ type Controller struct {
 	inproxyNATStateManager                  *InproxyNATStateManager
 	inproxyHandleTacticsMutex               sync.Mutex
 	inproxyLastStoredTactics                time.Time
+	establishSignalForceTacticsFetch        chan struct{}
+	inproxyClientDialRateLimiter            *rate.Limiter
 }
 
 // NewController initializes a new controller.
@@ -116,7 +119,7 @@ func NewController(config *Config) (controller *Controller, err error) {
 	// ensures no tactics request is attempted now.
 	doneContext, cancelFunc := context.WithCancel(context.Background())
 	cancelFunc()
-	GetTactics(doneContext, config)
+	GetTactics(doneContext, config, true)
 
 	p := config.GetParameters().Get()
 	splitTunnelClassificationTTL :=
@@ -1505,6 +1508,8 @@ type protocolSelectionConstraints struct {
 	limitTunnelDialPortNumbers                protocol.TunnelProtocolPortLists
 	limitQUICVersions                         protocol.QUICVersions
 	replayCandidateCount                      int
+	isInproxyPersonalPairingMode              bool
+	inproxyClientDialRateLimiter              *rate.Limiter
 }
 
 func (p *protocolSelectionConstraints) hasInitialProtocols() bool {
@@ -1556,24 +1561,30 @@ func (p *protocolSelectionConstraints) canReplay(
 		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 &&
 		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(
 		conditionallyEnabledComponents{},
 		p.useUpstreamProxy,
-		limitTunnelProtocols,
+		p.getLimitTunnelProtocols(connectTunnelCount),
 		p.limitTunnelDialPortNumbers,
 		p.limitQUICVersions,
 		excludeIntensive,
@@ -1584,13 +1595,13 @@ func (p *protocolSelectionConstraints) selectProtocol(
 	connectTunnelCount int,
 	excludeIntensive bool,
 	excludeInproxy bool,
-	serverEntry *protocol.ServerEntry) (string, bool) {
+	serverEntry *protocol.ServerEntry) (string, time.Duration, bool) {
 
 	candidateProtocols := p.supportedProtocols(
 		connectTunnelCount, excludeIntensive, excludeInproxy, serverEntry)
 
 	if len(candidateProtocols) == 0 {
-		return "", false
+		return "", 0, false
 	}
 
 	// 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
 	// 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 {
@@ -1667,6 +1731,30 @@ func (controller *Controller) startEstablishing() {
 	// controller.serverAffinityDoneBroadcast.
 	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)
 	go controller.launchEstablishing()
 }
@@ -1675,8 +1763,9 @@ func (controller *Controller) launchEstablishing() {
 
 	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
 	// 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
 	// 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 {
 
 		timeout := controller.config.GetParameters().Get().Duration(
 			parameters.TacticsWaitPeriod)
 
-		tacticsDone := make(chan struct{})
+		initialTacticsDone := make(chan struct{})
 		tacticsWaitPeriod := time.NewTimer(timeout)
 		defer tacticsWaitPeriod.Stop()
 
 		controller.establishWaitGroup.Add(1)
 		go func() {
 			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 {
-		case <-tacticsDone:
+		case <-initialTacticsDone:
 		case <-tacticsWaitPeriod.C:
 		}
 
@@ -1741,6 +1880,9 @@ func (controller *Controller) launchEstablishing() {
 			p.TunnelProtocolPortLists(parameters.LimitTunnelDialPortNumbers)),
 
 		replayCandidateCount: p.Int(parameters.ReplayCandidateCount),
+
+		isInproxyPersonalPairingMode: controller.config.IsInproxyPersonalPairingMode(),
+		inproxyClientDialRateLimiter: controller.inproxyClientDialRateLimiter,
 	}
 
 	// 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
 	// protocols are disabled.
 
-	if len(controller.config.InproxyClientPersonalCompartmentIDs) > 0 {
+	if controller.config.IsInproxyPersonalPairingMode() {
 
 		if len(controller.protocolSelectionConstraints.initialLimitTunnelProtocols) > 0 {
 			controller.protocolSelectionConstraints.initialLimitTunnelProtocols =
 				controller.protocolSelectionConstraints.
-					initialLimitTunnelProtocols.OnlyInproxyTunnelProtocols()
+					initialLimitTunnelProtocols.PruneNonInproxyTunnelProtocols()
 		}
 
 		if len(controller.protocolSelectionConstraints.limitTunnelProtocols) > 0 {
 			controller.protocolSelectionConstraints.limitTunnelProtocols =
 				controller.protocolSelectionConstraints.
-					limitTunnelProtocols.OnlyInproxyTunnelProtocols()
+					limitTunnelProtocols.PruneNonInproxyTunnelProtocols()
 		}
 
 		// This covers two cases: if there was no limitTunnelProtocols to
@@ -1773,8 +1915,17 @@ func (controller *Controller) launchEstablishing() {
 	}
 
 	// 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
 	// 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.candidateServerEntries = nil
 	controller.serverAffinityDoneBroadcast = nil
+	controller.establishSignalForceTacticsFetch = nil
+	controller.inproxyClientDialRateLimiter = nil
 
 	controller.concurrentEstablishTunnelsMutex.Lock()
 	peakConcurrent := controller.peakConcurrentEstablishTunnels
@@ -2141,11 +2294,56 @@ loop:
 		// No fetches are triggered when TargetServerEntry is specified. In that
 		// 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.
@@ -2297,20 +2495,32 @@ loop:
 				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) {
 
 			// The in-proxy protocol selection probability allows for
 			// tuning/limiting in-proxy usage independent of
 			// LimitTunnelProtocol targeting.
 
-			onlyInproxy := len(controller.config.InproxyClientPersonalCompartmentIDs) > 0
+			onlyInproxy := controller.config.IsInproxyPersonalPairingMode()
 			includeInproxy := onlyInproxy || prng.FlipWeightedCoin(inproxySelectionProbability)
 
-			return controller.protocolSelectionConstraints.selectProtocol(
+			selectedProtocol, rateLimitDelay, ok := controller.protocolSelectionConstraints.selectProtocol(
 				controller.establishConnectTunnelCount,
 				excludeIntensive,
 				!includeInproxy,
 				serverEntry)
+
+			dialRateLimitDelay = rateLimitDelay
+
+			return selectedProtocol, ok
 		}
 
 		// MakeDialParameters may return a replay instance, if the server
@@ -2390,6 +2600,8 @@ loop:
 
 		controller.concurrentEstablishTunnelsMutex.Unlock()
 
+		startStagger := time.Now()
+
 		// Apply stagger only now that we're past MakeDialParameters and
 		// protocol selection logic which may have caused the candidate to be
 		// skipped. The stagger logic delays dialing, and we don't want to
@@ -2412,6 +2624,15 @@ loop:
 			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
 		// reclaim as much as possible.
 		DoGarbageCollection()
@@ -2510,7 +2731,7 @@ func (controller *Controller) runInproxyProxy() {
 			// When not running client tunnel establishment, perform an OOB tactics
 			// fetch, if required, here.
 
-			GetTactics(controller.runCtx, controller.config)
+			GetTactics(controller.runCtx, controller.config, true)
 
 		} else if !controller.config.InproxySkipAwaitFullyConnected {
 
@@ -2849,8 +3070,7 @@ func (controller *Controller) inproxyHandleProxyTacticsPayload(
 		return false
 	}
 
-	if tacticsRecord != nil &&
-		prng.FlipWeightedCoin(tacticsRecord.Tactics.Probability) {
+	if tacticsRecord != nil {
 
 		// SetParameters signals registered components, including broker
 		// 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"
 	"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/quic"
 	"github.com/elazarl/goproxy"
@@ -300,29 +301,37 @@ func TestFrontedQUIC(t *testing.T) {
 
 func TestInproxyOSSH(t *testing.T) {
 
-	t.Skipf("temporarily disabled")
+	if !inproxy.Enabled() {
+		t.Skip("In-proxy is not enabled")
+	}
 
 	controllerRun(t,
 		&controllerRunConfig{
 			protocol:                 "INPROXY-WEBRTC-OSSH",
 			disableUntunneledUpgrade: true,
+			useInproxyDialRateLimit:  true,
 		})
 }
 
 func TestInproxyQUICOSSH(t *testing.T) {
 
-	t.Skipf("temporarily disabled")
+	if !inproxy.Enabled() {
+		t.Skip("In-proxy is not enabled")
+	}
 
 	controllerRun(t,
 		&controllerRunConfig{
 			protocol:                 "INPROXY-WEBRTC-QUIC-OSSH",
 			disableUntunneledUpgrade: true,
+			useInproxyDialRateLimit:  true,
 		})
 }
 
 func TestInproxyUnfrontedMeekHTTPS(t *testing.T) {
 
-	t.Skipf("temporarily disabled")
+	if !inproxy.Enabled() {
+		t.Skip("In-proxy is not enabled")
+	}
 
 	controllerRun(t,
 		&controllerRunConfig{
@@ -333,7 +342,9 @@ func TestInproxyUnfrontedMeekHTTPS(t *testing.T) {
 
 func TestInproxyTLSOSSH(t *testing.T) {
 
-	t.Skipf("temporarily disabled")
+	if !inproxy.Enabled() {
+		t.Skip("In-proxy is not enabled")
+	}
 
 	controllerRun(t,
 		&controllerRunConfig{
@@ -372,6 +383,7 @@ type controllerRunConfig struct {
 	transformHostNames       bool
 	useFragmentor            bool
 	useLegacyAPIEncoding     bool
+	useInproxyDialRateLimit  bool
 }
 
 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
 	}
 
+	if runConfig.useInproxyDialRateLimit {
+		modifyConfig["InproxyClientDialRateLimitQuantity"] = 2
+		modifyConfig["InproxyClientDialRateLimitIntervalMilliseconds"] = 1000
+	}
+
 	configJSON, _ = json.Marshal(modifyConfig)
 
 	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
 // 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.
 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) {
-		return constraints.selectProtocol(0, false, false, serverEntry)
+		protocol, _, ok := constraints.selectProtocol(0, false, false, serverEntry)
+		return protocol, ok
 	}
 
 	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
 	//   (the caller should ensure a network ID of "VPN" in this case).
 
-	GetTactics(getTacticsCtx, config)
+	GetTactics(getTacticsCtx, config, true)
 
 	// Get the latest client parameters
 	p = config.GetParameters().Get()

+ 29 - 6
psiphon/inproxy.go

@@ -247,16 +247,39 @@ func NewInproxyBrokerClientInstance(
 		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
 	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 {
-		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 {
 		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,
 			RequestObfuscatedKey: decodedTacticsRequestObfuscatedKey,
 			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)
 			}
 
-			if tacticsRecord != nil &&
-				prng.FlipWeightedCoin(tacticsRecord.Tactics.Probability) {
+			if tacticsRecord != nil {
 
 				err := serverContext.tunnel.config.SetParameters(
 					tacticsRecord.Tag, true, tacticsRecord.Tactics.Parameters)
@@ -1025,6 +1024,14 @@ func getBaseAPIParameters(
 
 	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 {
 		// 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.

+ 33 - 12
psiphon/tactics.go

@@ -49,7 +49,15 @@ import (
 // and without blocking the Controller from starting. Accessing tactics is
 // most critical for untunneled network operations; when a Controller is
 // 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
 	// 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
 	//    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 {
@@ -125,6 +138,13 @@ func GetTactics(ctx context.Context, config *Config) {
 
 			if err == 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 result.
 					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(
 			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.
 	DoGarbageCollection()
 	emitMemoryMetrics()
+
+	return
 }
 
 // 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.
 	CloseDataStore()
 
-	GetTactics(ctx, config)
+	GetTactics(ctx, config, true)
 
 	if atomic.LoadInt32(&gotTactics) != 1 {
 		t.Fatalf("failed to get tactics")