瀏覽代碼

Merge branch 'master' into staging-client

Rod Hynes 1 年之前
父節點
當前提交
623a05590b
共有 62 個文件被更改,包括 2664 次插入1534 次删除
  1. 118 549
      MobileLibrary/Android/PsiphonTunnel/PsiphonTunnel.java
  2. 0 1
      MobileLibrary/Android/PsiphonTunnel/libs/.gitignore
  3. 二進制
      MobileLibrary/Android/PsiphonTunnel/libs/arm64-v8a/libtun2socks.so
  4. 二進制
      MobileLibrary/Android/PsiphonTunnel/libs/armeabi-v7a/libtun2socks.so
  5. 二進制
      MobileLibrary/Android/PsiphonTunnel/libs/x86/libtun2socks.so
  6. 二進制
      MobileLibrary/Android/PsiphonTunnel/libs/x86_64/libtun2socks.so
  7. 0 12
      MobileLibrary/Android/SampleApps/TunneledWebView/.idea/runConfigurations.xml
  8. 0 15
      MobileLibrary/Android/SampleApps/TunneledWebView/app/src/main/java/ca/psiphon/tunneledwebview/MainActivity.java
  9. 0 4
      MobileLibrary/Android/make.bash
  10. 1 1
      MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/PsiphonTunnel.m
  11. 11 0
      psiphon/common/api.go
  12. 5 1
      psiphon/common/authPackage.go
  13. 8 1
      psiphon/common/inproxy/api.go
  14. 68 25
      psiphon/common/inproxy/broker.go
  15. 6 3
      psiphon/common/inproxy/client.go
  16. 9 0
      psiphon/common/inproxy/coordinator.go
  17. 7 0
      psiphon/common/inproxy/coordinator_test.go
  18. 0 1
      psiphon/common/inproxy/inproxy_test.go
  19. 184 35
      psiphon/common/inproxy/matcher.go
  20. 130 31
      psiphon/common/inproxy/matcher_test.go
  21. 36 10
      psiphon/common/inproxy/proxy.go
  22. 1 2
      psiphon/common/inproxy/session_test.go
  23. 21 12
      psiphon/common/packetman/packetman.go
  24. 40 3
      psiphon/common/parameters/parameters.go
  25. 4 0
      psiphon/common/parameters/parameters_test.go
  26. 2 2
      psiphon/common/prng/prng.go
  27. 4 5
      psiphon/common/quic/obfuscator.go
  28. 4 1
      psiphon/common/resolver/resolver.go
  29. 1 1
      psiphon/common/resolver/resolver_test.go
  30. 147 37
      psiphon/common/tactics/tactics.go
  31. 73 0
      psiphon/common/tactics/tactics_test.go
  32. 2 2
      psiphon/common/utils.go
  33. 23 0
      psiphon/config.go
  34. 18 9
      psiphon/controller.go
  35. 14 11
      psiphon/dataStore.go
  36. 0 12
      psiphon/dialParameters.go
  37. 23 10
      psiphon/feedback.go
  38. 202 1
      psiphon/feedback_test.go
  39. 449 0
      psiphon/frontedHTTP.go
  40. 172 0
      psiphon/frontedHTTP_test.go
  41. 528 0
      psiphon/frontingDialParameters.go
  42. 66 360
      psiphon/inproxy.go
  43. 14 16
      psiphon/inproxy_test.go
  44. 36 260
      psiphon/net.go
  45. 2 3
      psiphon/notice.go
  46. 7 1
      psiphon/remoteServerList.go
  47. 1 1
      psiphon/server/api.go
  48. 23 8
      psiphon/server/config.go
  49. 5 1
      psiphon/server/demux.go
  50. 3 1
      psiphon/server/listener.go
  51. 38 1
      psiphon/server/meek.go
  52. 1 1
      psiphon/server/replay.go
  53. 3 3
      psiphon/server/server_test.go
  54. 9 2
      psiphon/server/services.go
  55. 11 2
      psiphon/server/tactics.go
  56. 49 28
      psiphon/server/tunnelServer.go
  57. 12 4
      psiphon/server/udp.go
  58. 17 7
      psiphon/serverApi.go
  59. 11 7
      psiphon/tactics.go
  60. 10 7
      psiphon/tlsDialer.go
  61. 33 24
      psiphon/tunnel.go
  62. 2 0
      psiphon/upgradeDownload.go

文件差異過大導致無法顯示
+ 118 - 549
MobileLibrary/Android/PsiphonTunnel/PsiphonTunnel.java


+ 0 - 1
MobileLibrary/Android/PsiphonTunnel/libs/.gitignore

@@ -1 +0,0 @@
-!*.so

二進制
MobileLibrary/Android/PsiphonTunnel/libs/arm64-v8a/libtun2socks.so


二進制
MobileLibrary/Android/PsiphonTunnel/libs/armeabi-v7a/libtun2socks.so


二進制
MobileLibrary/Android/PsiphonTunnel/libs/x86/libtun2socks.so


二進制
MobileLibrary/Android/PsiphonTunnel/libs/x86_64/libtun2socks.so


+ 0 - 12
MobileLibrary/Android/SampleApps/TunneledWebView/.idea/runConfigurations.xml

@@ -1,12 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<project version="4">
-  <component name="RunConfigurationProducerService">
-    <option name="ignoredProducers">
-      <set>
-        <option value="org.jetbrains.plugins.gradle.execution.test.runner.AllInPackageGradleConfigurationProducer" />
-        <option value="org.jetbrains.plugins.gradle.execution.test.runner.TestClassGradleConfigurationProducer" />
-        <option value="org.jetbrains.plugins.gradle.execution.test.runner.TestMethodGradleConfigurationProducer" />
-      </set>
-    </option>
-  </component>
-</project>

+ 0 - 15
MobileLibrary/Android/SampleApps/TunneledWebView/app/src/main/java/ca/psiphon/tunneledwebview/MainActivity.java

@@ -166,26 +166,11 @@ public class MainActivity extends AppCompatActivity
     // NOTE: these are callbacks from the Psiphon Library
     //----------------------------------------------------------------------------------------------
 
-    @Override
-    public String getAppName() {
-        return "TunneledWebView Sample";
-    }
-
     @Override
     public Context getContext() {
         return this;
     }
 
-    @Override
-    public Object getVpnService() {
-        return null;
-    }
-
-    @Override
-    public Object newVpnServiceBuilder() {
-        return null;
-    }
-
     @Override
     public String getPsiphonConfig() {
         try {

+ 0 - 4
MobileLibrary/Android/make.bash

@@ -48,10 +48,6 @@ fi
 mkdir -p build-tmp/psi
 unzip -o psi.aar -d build-tmp/psi
 yes | cp -f PsiphonTunnel/AndroidManifest.xml build-tmp/psi/AndroidManifest.xml
-yes | cp -f PsiphonTunnel/libs/armeabi-v7a/libtun2socks.so build-tmp/psi/jni/armeabi-v7a/libtun2socks.so
-yes | cp -f PsiphonTunnel/libs/arm64-v8a/libtun2socks.so build-tmp/psi/jni/arm64-v8a/libtun2socks.so
-yes | cp -f PsiphonTunnel/libs/x86/libtun2socks.so build-tmp/psi/jni/x86/libtun2socks.so
-yes | cp -f PsiphonTunnel/libs/x86_64/libtun2socks.so build-tmp/psi/jni/x86_64/libtun2socks.so
 mkdir -p build-tmp/psi/res/xml
 yes | cp -f PsiphonTunnel/ca_psiphon_psiphontunnel_backup_rules.xml build-tmp/psi/res/xml/ca_psiphon_psiphontunnel_backup_rules.xml
 

+ 1 - 1
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/PsiphonTunnel.m

@@ -1199,7 +1199,7 @@ typedef NS_ERROR_ENUM(PsiphonTunnelErrorDomain, PsiphonTunnelErrorCode) {
     else if ([noticeType isEqualToString:@"ConnectedServerRegion"]) {
         id region = [notice valueForKeyPath:@"data.serverRegion"];
         if (![region isKindOfClass:[NSString class]]) {
-            [self logMessage:[NSString stringWithFormat: @"ActiveTunnel notice missing data.serverRegion: %@", noticeJSON]];
+            [self logMessage:[NSString stringWithFormat: @"ConnectedServerRegion notice missing data.serverRegion: %@", noticeJSON]];
             return;
         }
         if ([self.tunneledAppDelegate respondsToSelector:@selector(onConnectedServerRegion:)]) {

+ 11 - 0
psiphon/common/api.go

@@ -24,6 +24,17 @@ package common
 // values are of varying types: strings, ints, arrays, structs, etc.
 type APIParameters map[string]interface{}
 
+// Add copies API parameters from b to a, skipping parameters which already
+// exist, regardless of value, in a.
+func (a APIParameters) Add(b APIParameters) {
+	for name, value := range b {
+		_, ok := a[name]
+		if !ok {
+			a[name] = value
+		}
+	}
+}
+
 // APIParameterValidator is a function that validates API parameters
 // for a particular request or context.
 type APIParameterValidator func(APIParameters) error

+ 5 - 1
psiphon/common/authPackage.go

@@ -408,7 +408,11 @@ func (streamer *limitedJSONStreamer) Stream() error {
 
 			case stateJSONSeekingStringValueStart:
 				if b == '"' {
-					state = stateJSONSeekingStringValueEnd
+
+					// Note: this assignment is flagged by github.com/gordonklaus/ineffassign,
+					// but is technically the correct state.
+					//
+					//state = stateJSONSeekingStringValueEnd
 
 					key := keyBuffer.String()
 

+ 8 - 1
psiphon/common/inproxy/api.go

@@ -215,7 +215,7 @@ type ClientMetrics struct {
 // ProxyAnnounceRequest is an API request sent from a proxy to a broker,
 // announcing that it is available for a client connection. Proxies send one
 // ProxyAnnounceRequest for each available client connection. The broker will
-// match the proxy with a a client and return WebRTC connection information
+// match the proxy with a client and return WebRTC connection information
 // in the response.
 //
 // PersonalCompartmentIDs limits the clients to those that supply one of the
@@ -223,11 +223,18 @@ type ClientMetrics struct {
 // proxy operators to client users out-of-band and provide optional access
 // control.
 //
+// When CheckTactics is set, the broker will check for new tactics or indicate
+// that the proxy's cached tactics TTL may be extended. Tactics information
+// is returned in the response TacticsPayload. To minimize broker processing
+// overhead, proxies with multiple workers should designate just one worker
+// to set CheckTactics.
+//
 // The proxy's session public key is an implicit and cryptographically
 // verified proxy ID.
 type ProxyAnnounceRequest struct {
 	PersonalCompartmentIDs []ID          `cbor:"1,keyasint,omitempty"`
 	Metrics                *ProxyMetrics `cbor:"2,keyasint,omitempty"`
+	CheckTactics           bool          `cbor:"3,keyasint,omitempty"`
 }
 
 // WebRTCSessionDescription is compatible with pion/webrtc.SessionDescription

+ 68 - 25
psiphon/common/inproxy/broker.go

@@ -122,6 +122,15 @@ type BrokerConfig struct {
 	// clients. Proxies with personal compartment IDs are always allowed.
 	AllowProxy func(common.GeoIPData) bool
 
+	// PrioritizeProxy is a callback which can indicate whether proxy
+	// announcements from proxies with the specified GeoIPData and
+	// APIParameters should be prioritized in the matcher queue. Priority
+	// proxy announcements match ahead of other proxy announcements,
+	// regardless of announcement age/deadline. Priority status takes
+	// precedence over preferred NAT matching. Prioritization applies only to
+	// common compartment IDs and not personal pairing mode.
+	PrioritizeProxy func(common.GeoIPData, common.APIParameters) bool
+
 	// AllowClient is a callback which can indicate whether a client with the
 	// given GeoIP data is allowed to match with common compartment ID
 	// proxies. Clients are always allowed to match based on personal
@@ -155,6 +164,14 @@ type BrokerConfig struct {
 	// entry tags.
 	IsValidServerEntryTag func(serverEntryTag string) bool
 
+	// IsLoadLimiting is a callback which checks if the broker process is in a
+	// load limiting state, where consumed resources, including allocated
+	// system memory and CPU load, exceed determined thresholds. When load
+	// limiting is indicated, the broker will attempt to reduce load by
+	// immediately rejecting either proxy announces or client offers,
+	// depending on the state of the corresponding queues.
+	IsLoadLimiting func() bool
+
 	// PrivateKey is the broker's secure session long term private key.
 	PrivateKey SessionPrivateKey
 
@@ -233,6 +250,8 @@ func NewBroker(config *BrokerConfig) (*Broker, error) {
 			OfferLimitEntryCount:           config.MatcherOfferLimitEntryCount,
 			OfferRateLimitQuantity:         config.MatcherOfferRateLimitQuantity,
 			OfferRateLimitInterval:         config.MatcherOfferRateLimitInterval,
+
+			IsLoadLimiting: config.IsLoadLimiting,
 		}),
 
 		proxyAnnounceTimeout:       int64(config.ProxyAnnounceTimeout),
@@ -548,6 +567,8 @@ func (b *Broker) handleProxyAnnounce(
 		return nil, errors.Trace(err)
 	}
 
+	hasPersonalCompartmentIDs := len(announceRequest.PersonalCompartmentIDs) > 0
+
 	// Return MustUpgrade when the proxy's protocol version is less than the
 	// minimum required.
 	if announceRequest.Metrics.ProxyProtocolVersion < MinimumProxyProtocolVersion {
@@ -573,22 +594,25 @@ func (b *Broker) handleProxyAnnounce(
 	// proxy can store and apply the new tactics before announcing again.
 
 	var tacticsPayload []byte
-	tacticsPayload, newTacticsTag, err = b.config.GetTacticsPayload(geoIPData, apiParams)
-	if err != nil {
-		return nil, errors.Trace(err)
-	}
-
-	if tacticsPayload != nil && newTacticsTag != "" {
-		responsePayload, err := MarshalProxyAnnounceResponse(
-			&ProxyAnnounceResponse{
-				TacticsPayload: tacticsPayload,
-				NoMatch:        true,
-			})
+	if announceRequest.CheckTactics {
+		tacticsPayload, newTacticsTag, err =
+			b.config.GetTacticsPayload(geoIPData, apiParams)
 		if err != nil {
 			return nil, errors.Trace(err)
 		}
 
-		return responsePayload, nil
+		if tacticsPayload != nil && newTacticsTag != "" {
+			responsePayload, err := MarshalProxyAnnounceResponse(
+				&ProxyAnnounceResponse{
+					TacticsPayload: tacticsPayload,
+					NoMatch:        true,
+				})
+			if err != nil {
+				return nil, errors.Trace(err)
+			}
+
+			return responsePayload, nil
+		}
 	}
 
 	// AllowProxy may be used to disallow proxies from certain geolocations,
@@ -596,7 +620,7 @@ func (b *Broker) handleProxyAnnounce(
 	// compartment IDs are always allowed, as they will be used only by
 	// clients specifically configured to use them.
 
-	if len(announceRequest.PersonalCompartmentIDs) == 0 &&
+	if !hasPersonalCompartmentIDs &&
 		!b.config.AllowProxy(geoIPData) {
 
 		return nil, errors.TraceNew("proxy disallowed")
@@ -608,7 +632,7 @@ func (b *Broker) handleProxyAnnounce(
 	// assigned to the same compartment.
 
 	var commonCompartmentIDs []ID
-	if len(announceRequest.PersonalCompartmentIDs) == 0 {
+	if !hasPersonalCompartmentIDs {
 		compartmentID, err := b.selectCommonCompartmentID(proxyID)
 		if err != nil {
 			return nil, errors.Trace(err)
@@ -616,6 +640,28 @@ func (b *Broker) handleProxyAnnounce(
 		commonCompartmentIDs = []ID{compartmentID}
 	}
 
+	// In the common compartment ID case, invoke the callback to check if the
+	// announcement should be prioritized.
+
+	isPriority := false
+	if b.config.PrioritizeProxy != nil && !hasPersonalCompartmentIDs {
+
+		// Limitation: Of the two return values from
+		// ValidateAndGetParametersAndLogFields, apiParams and logFields,
+		// only logFields contains fields such as max_clients
+		// and *_bytes_per_second, and so these cannot be part of any
+		// filtering performed by the PrioritizeProxy callback.
+		//
+		// TODO: include the additional fields in logFields. Since the
+		// logFields return value is the output of server.getRequestLogFields
+		// processing, it's not safe to use it directly. In addition,
+		// filtering by fields such as max_clients and *_bytes_per_second
+		// calls for range filtering, which is not yet supported in the
+		// psiphon/server.MeekServer PrioritizeProxy provider.
+
+		isPriority = b.config.PrioritizeProxy(geoIPData, apiParams)
+	}
+
 	// Await client offer.
 
 	timeout := common.ValueOrDefault(
@@ -638,6 +684,7 @@ func (b *Broker) handleProxyAnnounce(
 		proxyIP,
 		&MatchAnnouncement{
 			Properties: MatchProperties{
+				IsPriority:             isPriority,
 				CommonCompartmentIDs:   commonCompartmentIDs,
 				PersonalCompartmentIDs: announceRequest.PersonalCompartmentIDs,
 				GeoIPData:              geoIPData,
@@ -830,6 +877,8 @@ func (b *Broker) handleClientOffer(
 		return nil, errors.Trace(err)
 	}
 
+	hasPersonalCompartmentIDs := len(offerRequest.PersonalCompartmentIDs) > 0
+
 	offerSDP := offerRequest.ClientOfferSDP
 	offerSDP.SDP = string(filteredSDP)
 
@@ -837,16 +886,10 @@ func (b *Broker) handleClientOffer(
 	// from offering. Clients are always allowed to match proxies with shared
 	// personal compartment IDs.
 
-	commonCompartmentIDs := offerRequest.CommonCompartmentIDs
-
-	if !b.config.AllowClient(geoIPData) {
-
-		if len(offerRequest.PersonalCompartmentIDs) == 0 {
-			return nil, errors.TraceNew("client disallowed")
-		}
+	if !hasPersonalCompartmentIDs &&
+		!b.config.AllowClient(geoIPData) {
 
-		// Only match personal compartment IDs.
-		commonCompartmentIDs = nil
+		return nil, errors.TraceNew("client disallowed")
 	}
 
 	// Validate that the proxy destination specified by the client is a valid
@@ -884,7 +927,7 @@ func (b *Broker) handleClientOffer(
 	// personal pairing mode, to facilitate a faster no-match result and
 	// resulting broker rotation.
 	var timeout time.Duration
-	if len(offerRequest.PersonalCompartmentIDs) > 0 {
+	if hasPersonalCompartmentIDs {
 		timeout = time.Duration(atomic.LoadInt64(&b.clientOfferPersonalTimeout))
 	} else {
 		timeout = time.Duration(atomic.LoadInt64(&b.clientOfferTimeout))
@@ -901,7 +944,7 @@ func (b *Broker) handleClientOffer(
 
 	clientMatchOffer = &MatchOffer{
 		Properties: MatchProperties{
-			CommonCompartmentIDs:   commonCompartmentIDs,
+			CommonCompartmentIDs:   offerRequest.CommonCompartmentIDs,
 			PersonalCompartmentIDs: offerRequest.PersonalCompartmentIDs,
 			GeoIPData:              geoIPData,
 			NetworkType:            GetNetworkType(offerRequest.Metrics.BaseAPIParameters),

+ 6 - 3
psiphon/common/inproxy/client.go

@@ -44,7 +44,6 @@ const (
 // initial dial address.
 type ClientConn struct {
 	config       *ClientConfig
-	brokerClient *BrokerClient
 	webRTCConn   *webRTCConn
 	connectionID ID
 	remoteAddr   net.Addr
@@ -356,7 +355,11 @@ func dialClientWebRTCConn(
 
 	// Send the ClientOffer request to the broker
 
-	packedBaseParams, err := protocol.EncodePackedAPIParameters(config.BaseAPIParameters)
+	apiParams := common.APIParameters{}
+	apiParams.Add(config.BaseAPIParameters)
+	apiParams.Add(common.APIParameters(brokerCoordinator.MetricsForBrokerRequests()))
+
+	packedParams, err := protocol.EncodePackedAPIParameters(apiParams)
 	if err != nil {
 		return nil, false, errors.Trace(err)
 	}
@@ -372,7 +375,7 @@ func dialClientWebRTCConn(
 		ctx,
 		&ClientOfferRequest{
 			Metrics: &ClientMetrics{
-				BaseAPIParameters:    packedBaseParams,
+				BaseAPIParameters:    packedParams,
 				ProxyProtocolVersion: proxyProtocolVersion,
 				NATType:              config.WebRTCDialCoordinator.NATType(),
 				PortMappingTypes:     config.WebRTCDialCoordinator.PortMappingTypes(),

+ 9 - 0
psiphon/common/inproxy/coordinator.go

@@ -23,6 +23,8 @@ import (
 	"context"
 	"net"
 	"time"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
 )
 
 // RoundTripper provides a request/response round trip network transport with
@@ -170,6 +172,13 @@ type BrokerDialCoordinator interface {
 	// proxies are not well balanced across brokers.
 	BrokerClientNoMatch(roundTripper RoundTripper)
 
+	// MetricsForBrokerRequests returns the metrics, associated with the
+	// broker client instance, which are to be added to the base API
+	// parameters included in client and proxy requests sent to the broker.
+	// This includes fronting_provider_id, which varies depending on the
+	// broker client dial and isn't a fixed base API parameter value.
+	MetricsForBrokerRequests() common.LogFields
+
 	SessionHandshakeRoundTripTimeout() time.Duration
 	AnnounceRequestTimeout() time.Duration
 	AnnounceDelay() time.Duration

+ 7 - 0
psiphon/common/inproxy/coordinator_test.go

@@ -46,6 +46,7 @@ type testBrokerDialCoordinator struct {
 	brokerClientRoundTripperSucceeded func(RoundTripper)
 	brokerClientRoundTripperFailed    func(RoundTripper)
 	brokerClientNoMatch               func(RoundTripper)
+	metricsForBrokerRequests          common.LogFields
 	sessionHandshakeRoundTripTimeout  time.Duration
 	announceRequestTimeout            time.Duration
 	announceDelay                     time.Duration
@@ -124,6 +125,12 @@ func (t *testBrokerDialCoordinator) BrokerClientNoMatch(roundTripper RoundTrippe
 	t.brokerClientNoMatch(roundTripper)
 }
 
+func (t *testBrokerDialCoordinator) MetricsForBrokerRequests() common.LogFields {
+	t.mutex.Lock()
+	defer t.mutex.Unlock()
+	return t.metricsForBrokerRequests
+}
+
 func (t *testBrokerDialCoordinator) SessionHandshakeRoundTripTimeout() time.Duration {
 	t.mutex.Lock()
 	defer t.mutex.Unlock()

+ 0 - 1
psiphon/common/inproxy/inproxy_test.go

@@ -535,7 +535,6 @@ func runTestInproxy(doMustUpgrade bool) error {
 					DialAddress:                  addr,
 					PackedDestinationServerEntry: packedDestinationServerEntry,
 					MustUpgrade: func() {
-						fmt.Printf("HI!\n")
 						close(receivedClientMustUpgrade)
 						cancelDial()
 					},

+ 184 - 35
psiphon/common/inproxy/matcher.go

@@ -114,6 +114,7 @@ type Matcher struct {
 // MatchProperties specifies the compartment, GeoIP, and network topology
 // matching roperties of clients and proxies.
 type MatchProperties struct {
+	IsPriority             bool
 	CommonCompartmentIDs   []ID
 	PersonalCompartmentIDs []ID
 	GeoIPData              common.GeoIPData
@@ -271,7 +272,7 @@ type MatcherConfig struct {
 	// Logger is used to log events.
 	Logger common.Logger
 
-	// Accouncement queue limits.
+	// Announcement queue limits.
 	AnnouncementLimitEntryCount    int
 	AnnouncementRateLimitQuantity  int
 	AnnouncementRateLimitInterval  time.Duration
@@ -281,6 +282,9 @@ type MatcherConfig struct {
 	OfferLimitEntryCount   int
 	OfferRateLimitQuantity int
 	OfferRateLimitInterval time.Duration
+
+	// Broker process load limit state callback. See Broker.Config.
+	IsLoadLimiting func() bool
 }
 
 // NewMatcher creates a new Matcher.
@@ -431,6 +435,12 @@ func (m *Matcher) Announce(
 		return nil, nil, errors.TraceNew("unexpected compartment ID count")
 	}
 
+	isAnnouncement := true
+	err := m.applyLoadLimit(isAnnouncement)
+	if err != nil {
+		return nil, nil, errors.Trace(err)
+	}
+
 	announcementEntry := &announcementEntry{
 		ctx:          ctx,
 		limitIP:      getRateLimitIP(proxyIP),
@@ -438,7 +448,7 @@ func (m *Matcher) Announce(
 		offerChan:    make(chan *MatchOffer, 1),
 	}
 
-	err := m.addAnnouncementEntry(announcementEntry)
+	err = m.addAnnouncementEntry(announcementEntry)
 	if err != nil {
 		return nil, nil, errors.Trace(err)
 	}
@@ -485,6 +495,12 @@ func (m *Matcher) Offer(
 		return nil, nil, nil, errors.TraceNew("unexpected missing compartment IDs")
 	}
 
+	isAnnouncement := false
+	err := m.applyLoadLimit(isAnnouncement)
+	if err != nil {
+		return nil, nil, nil, errors.Trace(err)
+	}
+
 	offerEntry := &offerEntry{
 		ctx:        ctx,
 		limitIP:    getRateLimitIP(clientIP),
@@ -492,7 +508,7 @@ func (m *Matcher) Offer(
 		answerChan: make(chan *answerInfo, 1),
 	}
 
-	err := m.addOfferEntry(offerEntry)
+	err = m.addOfferEntry(offerEntry)
 	if err != nil {
 		return nil, nil, nil, errors.Trace(err)
 	}
@@ -775,16 +791,27 @@ func (m *Matcher) matchOffer(offerEntry *offerEntry) (*announcementEntry, int) {
 
 	var bestMatch *announcementEntry
 	bestMatchIndex := -1
+	bestMatchIsPriority := false
 	bestMatchNAT := false
 
 	candidateIndex := -1
 	for {
 
-		announcementEntry := matchIterator.getNext()
+		announcementEntry, isPriority := matchIterator.getNext()
 		if announcementEntry == nil {
 			break
 		}
 
+		if !isPriority && bestMatchIsPriority {
+
+			// There is a priority match, but it wasn't bestMatchNAT and we
+			// continued to iterate. Now that isPriority is false, we're past the
+			// end of the priority items, so stop looking for any best NAT match
+			// and return the previous priority match. When there are zero
+			// priority items to begin with, this case should not be hit.
+			break
+		}
+
 		candidateIndex += 1
 
 		// Skip and remove this announcement if its deadline has already
@@ -829,8 +856,8 @@ func (m *Matcher) matchOffer(offerEntry *offerEntry) (*announcementEntry, int) {
 
 			bestMatch = announcementEntry
 			bestMatchIndex = candidateIndex
+			bestMatchIsPriority = isPriority
 			bestMatchNAT = matchNAT
-
 		}
 
 		// Stop as soon as we have the best possible match, or have reached
@@ -847,6 +874,79 @@ func (m *Matcher) matchOffer(offerEntry *offerEntry) (*announcementEntry, int) {
 	return bestMatch, bestMatchIndex
 }
 
+// applyLoadLimit checks if the broker process is in the load limiting state
+// and, in order to reduce load, determines if new proxy announces or client
+// offers should be rejected immediately instead of enqueued.
+func (m *Matcher) applyLoadLimit(isAnnouncement bool) error {
+
+	if m.config.IsLoadLimiting == nil || !m.config.IsLoadLimiting() {
+		return nil
+	}
+
+	// Acquire the queue locks only when in the load limit state, and in the
+	// same order as matchAllOffers.
+
+	m.announcementQueueMutex.Lock()
+	defer m.announcementQueueMutex.Unlock()
+	m.offerQueueMutex.Lock()
+	defer m.offerQueueMutex.Unlock()
+
+	announcementLen := m.announcementQueue.getLen()
+	offerLen := m.offerQueue.Len()
+
+	// When the load limit had been reached, and assuming the broker process
+	// is running only an in-proxy broker, it's likely, in practise, that
+	// only one of the two queues has hundreds of thousands of entries while
+	// the other has few, and there are no matches clearing the queue.
+	//
+	// Instead of simply rejecting all enqueue requests, allow the request
+	// type, announce or offer, that is in shorter supply as these are likely
+	// to match and draw down the larger queue. This attempts to make
+	// productive use of enqueued items, and also attempts to avoid simply
+	// emptying both queues -- as will happen in any case due to timeouts --
+	// and then have the same larger queue refill again after the load limit
+	// state exits.
+	//
+	// This approach assumes some degree of slack in available system memory
+	// and CPU in the load limiting state, similar to how the tunnel server
+	// continues to operate existing tunnels in the same state.
+	//
+	// The heuristic below of allowing when less than half the size of the
+	// larger queue puts a cap on the amount the shorter queue can continue
+	// to grow in the load limiting state, in the worst case.
+	//
+	// Limitation: in some scenarios that are expected to be rare, it can
+	// happen that allowed requests don't result in a match and memory
+	// consumption continues to grow, leading to a broker process OOM kill.
+
+	var allow bool
+	if isAnnouncement {
+		allow = announcementLen < offerLen/2
+	} else {
+		allow = offerLen < announcementLen/2
+	}
+	if allow {
+		return nil
+	}
+
+	// Do not return a MatcherLimitError, as is done in applyIPLimits. A
+	// MatcherLimitError results in a Response.Limited error response, which
+	// causes a proxy to back off and a client to abort its dial; but in
+	// neither case is the broker client reset. The error returned here will
+	// result in a fast 404 response to the proxy or client, which will
+	// instead trigger a broker client reset, and a chance of moving to a
+	// different broker that is not overloaded.
+	//
+	// Limitation: the 404 response won't be distinguishable, in client or
+	// proxy diagnostics, from other error conditions.
+	//
+	// TODO: add a new Response.LoadLimited flag which the proxy/client can
+	// use use log a distinct error and also ensure that it doesn't reselect
+	// the same broker again in the broker client reset random selection.
+
+	return errors.TraceNew("load limited")
+}
+
 // MatcherLimitError is the error type returned by Announce or Offer when the
 // caller has exceeded configured queue entry or rate limits.
 type MatcherLimitError struct {
@@ -861,9 +961,11 @@ func (e MatcherLimitError) Error() string {
 	return e.err.Error()
 }
 
-func (m *Matcher) applyLimits(isAnnouncement bool, limitIP string, proxyID ID) error {
+// applyIPLimits checks per-proxy or per-client -- as determined by peer IP
+// address -- rate limits and queue entry limits.
+func (m *Matcher) applyIPLimits(isAnnouncement bool, limitIP string, proxyID ID) error {
 
-	// Assumes the m.announcementQueueMutex or m.offerQueue mutex is locked.
+	// Assumes m.announcementQueueMutex or m.offerQueueMutex is locked.
 
 	var entryCountByIP map[string]int
 	var queueRateLimiters *lrucache.Cache
@@ -946,7 +1048,7 @@ func (m *Matcher) addAnnouncementEntry(announcementEntry *announcementEntry) err
 	// Ensure no single peer IP can enqueue a large number of entries or
 	// rapidly enqueue beyond the configured rate.
 	isAnnouncement := true
-	err := m.applyLimits(
+	err := m.applyIPLimits(
 		isAnnouncement, announcementEntry.limitIP, announcementEntry.announcement.ProxyID)
 	if err != nil {
 		return errors.Trace(err)
@@ -1029,7 +1131,7 @@ func (m *Matcher) addOfferEntry(offerEntry *offerEntry) error {
 	// Ensure no single peer IP can enqueue a large number of entries or
 	// rapidly enqueue beyond the configured rate.
 	isAnnouncement := false
-	err := m.applyLimits(
+	err := m.applyIPLimits(
 		isAnnouncement, offerEntry.limitIP, ID{})
 	if err != nil {
 		return errors.Trace(err)
@@ -1107,9 +1209,10 @@ func getRateLimitIP(strIP string) string {
 // matching a specified list of compartment IDs. announcementMultiQueue and
 // its underlying data structures are not safe for concurrent access.
 type announcementMultiQueue struct {
-	commonCompartmentQueues   map[ID]*announcementCompartmentQueue
-	personalCompartmentQueues map[ID]*announcementCompartmentQueue
-	totalEntries              int
+	priorityCommonCompartmentQueues map[ID]*announcementCompartmentQueue
+	commonCompartmentQueues         map[ID]*announcementCompartmentQueue
+	personalCompartmentQueues       map[ID]*announcementCompartmentQueue
+	totalEntries                    int
 }
 
 // announcementCompartmentQueue is a single compartment queue within an
@@ -1120,6 +1223,7 @@ type announcementMultiQueue struct {
 // matches may be possible.
 type announcementCompartmentQueue struct {
 	isCommonCompartment      bool
+	isPriority               bool
 	compartmentID            ID
 	entries                  *list.List
 	unlimitedNATCount        int
@@ -1131,11 +1235,10 @@ type announcementCompartmentQueue struct {
 // subset of announcementMultiQueue compartment queues. Concurrent
 // announcementMatchIterators are not supported.
 type announcementMatchIterator struct {
-	multiQueue           *announcementMultiQueue
-	isCommonCompartments bool
-	compartmentQueues    []*announcementCompartmentQueue
-	compartmentIDs       []ID
-	nextEntries          []*list.Element
+	multiQueue        *announcementMultiQueue
+	compartmentQueues []*announcementCompartmentQueue
+	compartmentIDs    []ID
+	nextEntries       []*list.Element
 }
 
 // announcementQueueReference represents the queue position for a given
@@ -1148,8 +1251,9 @@ type announcementQueueReference struct {
 
 func newAnnouncementMultiQueue() *announcementMultiQueue {
 	return &announcementMultiQueue{
-		commonCompartmentQueues:   make(map[ID]*announcementCompartmentQueue),
-		personalCompartmentQueues: make(map[ID]*announcementCompartmentQueue),
+		priorityCommonCompartmentQueues: make(map[ID]*announcementCompartmentQueue),
+		commonCompartmentQueues:         make(map[ID]*announcementCompartmentQueue),
+		personalCompartmentQueues:       make(map[ID]*announcementCompartmentQueue),
 	}
 }
 
@@ -1179,22 +1283,31 @@ func (q *announcementMultiQueue) enqueue(announcementEntry *announcementEntry) e
 		return errors.TraceNew("announcement must specify exactly one compartment ID")
 	}
 
+	isPriority := announcementEntry.announcement.Properties.IsPriority
+
 	isCommonCompartment := true
 	var compartmentID ID
 	var compartmentQueues map[ID]*announcementCompartmentQueue
 	if len(commonCompartmentIDs) > 0 {
 		compartmentID = commonCompartmentIDs[0]
 		compartmentQueues = q.commonCompartmentQueues
+		if isPriority {
+			compartmentQueues = q.priorityCommonCompartmentQueues
+		}
 	} else {
 		isCommonCompartment = false
 		compartmentID = personalCompartmentIDs[0]
 		compartmentQueues = q.personalCompartmentQueues
+		if isPriority {
+			return errors.TraceNew("priority not supported for personal compartments")
+		}
 	}
 
 	compartmentQueue, ok := compartmentQueues[compartmentID]
 	if !ok {
 		compartmentQueue = &announcementCompartmentQueue{
 			isCommonCompartment: isCommonCompartment,
+			isPriority:          isPriority,
 			compartmentID:       compartmentID,
 			entries:             list.New(),
 		}
@@ -1250,9 +1363,14 @@ func (r *announcementQueueReference) dequeue() bool {
 
 	if r.compartmentQueue.entries.Len() == 0 {
 		// Remove empty compartment queue.
-		queues := r.multiQueue.commonCompartmentQueues
-		if !r.compartmentQueue.isCommonCompartment {
-			queues = r.multiQueue.personalCompartmentQueues
+		queues := r.multiQueue.personalCompartmentQueues
+		if r.compartmentQueue.isCommonCompartment {
+			if r.compartmentQueue.isPriority {
+				queues = r.multiQueue.priorityCommonCompartmentQueues
+
+			} else {
+				queues = r.multiQueue.commonCompartmentQueues
+			}
 		}
 		delete(queues, r.compartmentQueue.compartmentID)
 	}
@@ -1270,8 +1388,7 @@ func (q *announcementMultiQueue) startMatching(
 	compartmentIDs []ID) *announcementMatchIterator {
 
 	iter := &announcementMatchIterator{
-		multiQueue:           q,
-		isCommonCompartments: isCommonCompartments,
+		multiQueue: q,
 	}
 
 	// Find the matching compartment queues and initialize iteration over
@@ -1280,16 +1397,29 @@ func (q *announcementMultiQueue) startMatching(
 	// maxCompartmentIDs, as enforced in
 	// ClientOfferRequest.ValidateAndGetLogFields).
 
-	compartmentQueues := q.commonCompartmentQueues
-	if !isCommonCompartments {
-		compartmentQueues = q.personalCompartmentQueues
+	// Priority queues, when in use, must all be added to the beginning of
+	// iter.compartmentQueues in order to ensure that the iteration logic in
+	// getNext visits all priority items first.
+
+	var compartmentQueuesList []map[ID]*announcementCompartmentQueue
+	if isCommonCompartments {
+		compartmentQueuesList = append(
+			compartmentQueuesList,
+			q.priorityCommonCompartmentQueues,
+			q.commonCompartmentQueues)
+	} else {
+		compartmentQueuesList = append(
+			compartmentQueuesList,
+			q.personalCompartmentQueues)
 	}
 
-	for _, ID := range compartmentIDs {
-		if compartmentQueue, ok := compartmentQueues[ID]; ok {
-			iter.compartmentQueues = append(iter.compartmentQueues, compartmentQueue)
-			iter.compartmentIDs = append(iter.compartmentIDs, ID)
-			iter.nextEntries = append(iter.nextEntries, compartmentQueue.entries.Front())
+	for _, compartmentQueues := range compartmentQueuesList {
+		for _, ID := range compartmentIDs {
+			if compartmentQueue, ok := compartmentQueues[ID]; ok {
+				iter.compartmentQueues = append(iter.compartmentQueues, compartmentQueue)
+				iter.compartmentIDs = append(iter.compartmentIDs, ID)
+				iter.nextEntries = append(iter.nextEntries, compartmentQueue.entries.Front())
+			}
 		}
 	}
 
@@ -1327,10 +1457,16 @@ func (iter *announcementMatchIterator) getNATCounts() (int, int, int) {
 // are not supported during iteration. Iteration and dequeue should all be
 // performed with a lock over the entire announcementMultiQueue, and with
 // only one concurrent announcementMatchIterator.
-func (iter *announcementMatchIterator) getNext() *announcementEntry {
+//
+// getNext returns a nil *announcementEntry when there are no more items.
+// getNext also returns an isPriority flag, indicating the announcement is a
+// priority candidate. All priority candidates are guaranteed to be returned
+// before any non-priority candidates.
+func (iter *announcementMatchIterator) getNext() (*announcementEntry, bool) {
 
 	// Assumes announcements are enqueued in announcementEntry.ctx.Deadline
-	// order.
+	// order. Also assumes that any priority queues are all at the front of
+	// iter.compartmentQueues.
 
 	// Select the oldest item, by deadline, from all the candidate queue head
 	// items. This operation is linear in the number of matching compartment
@@ -1338,6 +1474,10 @@ func (iter *announcementMatchIterator) getNext() *announcementEntry {
 	// compartment IDs (no more than maxCompartmentIDs, as enforced in
 	// ClientOfferRequest.ValidateAndGetLogFields).
 	//
+	// When there are priority candidates, they are selected first, regardless
+	// of the deadlines of non-priority candidates. Multiple priority
+	// candidates are processed in FIFO deadline order.
+	//
 	// A potential future enhancement is to add more iterator state to track
 	// which queue has the next oldest time to select on the following
 	// getNext call. Another potential enhancement is to remove fully
@@ -1345,14 +1485,22 @@ func (iter *announcementMatchIterator) getNext() *announcementEntry {
 
 	var selectedCandidate *announcementEntry
 	selectedIndex := -1
+	selectedPriority := false
 
 	for i := 0; i < len(iter.compartmentQueues); i++ {
 		if iter.nextEntries[i] == nil {
 			continue
 		}
+		isPriority := iter.compartmentQueues[i].isPriority
+		if selectedPriority && !isPriority {
+			// Ignore older of non-priority entries when there are priority
+			// candidates.
+			break
+		}
 		if selectedCandidate == nil {
 			selectedCandidate = iter.nextEntries[i].Value.(*announcementEntry)
 			selectedIndex = i
+			selectedPriority = isPriority
 		} else {
 			candidate := iter.nextEntries[i].Value.(*announcementEntry)
 			deadline, deadlineOk := candidate.ctx.Deadline()
@@ -1360,6 +1508,7 @@ func (iter *announcementMatchIterator) getNext() *announcementEntry {
 			if deadlineOk && selectedDeadlineOk && deadline.Before(selectedDeadline) {
 				selectedCandidate = candidate
 				selectedIndex = i
+				selectedPriority = isPriority
 			}
 		}
 	}
@@ -1371,5 +1520,5 @@ func (iter *announcementMatchIterator) getNext() *announcementEntry {
 		iter.nextEntries[selectedIndex] = iter.nextEntries[selectedIndex].Next()
 	}
 
-	return selectedCandidate
+	return selectedCandidate, selectedPriority
 }

+ 130 - 31
psiphon/common/inproxy/matcher_test.go

@@ -617,6 +617,31 @@ func runTestMatcher() error {
 		return errors.Trace(err)
 	}
 
+	// Test: priority supercedes preferred NAT match
+
+	go proxyFunc(proxy1ResultChan, proxyIP, proxy1Properties, 10*time.Millisecond, nil, true)
+	time.Sleep(5 * time.Millisecond) // Hack to ensure proxy is enqueued
+	proxy2Properties.IsPriority = true
+	go proxyFunc(proxy2ResultChan, proxyIP, proxy2Properties, 10*time.Millisecond, nil, true)
+	time.Sleep(5 * time.Millisecond) // Hack to ensure proxy is enqueued
+	go clientFunc(clientResultChan, clientIP, client2Properties, 10*time.Millisecond)
+
+	err = <-proxy1ResultChan
+	if err == nil || !strings.HasSuffix(err.Error(), "context deadline exceeded") {
+		return errors.Tracef("unexpected result: %v", err)
+	}
+
+	// proxy2 should match since it's the priority, but not preferred NAT match
+	err = <-proxy2ResultChan
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	err = <-clientResultChan
+	if err != nil {
+		return errors.Trace(err)
+	}
+
 	// Test: many matches
 
 	// Reduce test log noise for this phase of the test
@@ -675,24 +700,26 @@ func TestMatcherMultiQueue(t *testing.T) {
 
 func runTestMatcherMultiQueue() error {
 
-	q := newAnnouncementMultiQueue()
-
 	// Test: invalid compartment IDs
 
-	err := q.enqueue(&announcementEntry{
-		announcement: &MatchAnnouncement{
-			Properties: MatchProperties{}}})
+	q := newAnnouncementMultiQueue()
+
+	err := q.enqueue(
+		&announcementEntry{
+			announcement: &MatchAnnouncement{
+				Properties: MatchProperties{}}})
 	if err == nil {
 		return errors.TraceNew("unexpected success")
 	}
 
 	compartmentID, _ := MakeID()
-	err = q.enqueue(&announcementEntry{
-		announcement: &MatchAnnouncement{
-			Properties: MatchProperties{
-				CommonCompartmentIDs:   []ID{compartmentID},
-				PersonalCompartmentIDs: []ID{compartmentID},
-			}}})
+	err = q.enqueue(
+		&announcementEntry{
+			announcement: &MatchAnnouncement{
+				Properties: MatchProperties{
+					CommonCompartmentIDs:   []ID{compartmentID},
+					PersonalCompartmentIDs: []ID{compartmentID},
+				}}})
 	if err == nil {
 		return errors.TraceNew("unexpected success")
 	}
@@ -716,25 +743,27 @@ func runTestMatcherMultiQueue() error {
 		ctx, cancel := context.WithDeadline(
 			context.Background(), time.Now().Add(time.Duration(i+1)*time.Minute))
 		defer cancel()
-		err := q.enqueue(&announcementEntry{
-			ctx: ctx,
-			announcement: &MatchAnnouncement{
-				Properties: MatchProperties{
-					CommonCompartmentIDs: []ID{
-						otherCommonCompartmentIDs[i%numOtherCompartmentIDs]},
-					NATType: NATTypeSymmetric,
-				}}})
+		err := q.enqueue(
+			&announcementEntry{
+				ctx: ctx,
+				announcement: &MatchAnnouncement{
+					Properties: MatchProperties{
+						CommonCompartmentIDs: []ID{
+							otherCommonCompartmentIDs[i%numOtherCompartmentIDs]},
+						NATType: NATTypeSymmetric,
+					}}})
 		if err != nil {
 			return errors.Trace(err)
 		}
-		err = q.enqueue(&announcementEntry{
-			ctx: ctx,
-			announcement: &MatchAnnouncement{
-				Properties: MatchProperties{
-					PersonalCompartmentIDs: []ID{
-						otherPersonalCompartmentIDs[i%numOtherCompartmentIDs]},
-					NATType: NATTypeSymmetric,
-				}}})
+		err = q.enqueue(
+			&announcementEntry{
+				ctx: ctx,
+				announcement: &MatchAnnouncement{
+					Properties: MatchProperties{
+						PersonalCompartmentIDs: []ID{
+							otherPersonalCompartmentIDs[i%numOtherCompartmentIDs]},
+						NATType: NATTypeSymmetric,
+					}}})
 		if err != nil {
 			return errors.Trace(err)
 		}
@@ -797,7 +826,7 @@ func runTestMatcherMultiQueue() error {
 		return errors.TraceNew("unexpected NAT counts")
 	}
 
-	match := iter.getNext()
+	match, _ := iter.getNext()
 	if match == nil {
 		return errors.TraceNew("unexpected missing match")
 	}
@@ -826,7 +855,7 @@ func runTestMatcherMultiQueue() error {
 		return errors.TraceNew("unexpected NAT counts")
 	}
 
-	match = iter.getNext()
+	match, _ = iter.getNext()
 	if match == nil {
 		return errors.TraceNew("unexpected missing match")
 	}
@@ -844,7 +873,7 @@ func runTestMatcherMultiQueue() error {
 
 	// Test: getNext after dequeue
 
-	match = iter.getNext()
+	match, _ = iter.getNext()
 	if match == nil {
 		return errors.TraceNew("unexpected missing match")
 	}
@@ -856,7 +885,7 @@ func runTestMatcherMultiQueue() error {
 		return errors.TraceNew("unexpected already dequeued")
 	}
 
-	match = iter.getNext()
+	match, _ = iter.getNext()
 	if match == nil {
 		return errors.TraceNew("unexpected missing match")
 	}
@@ -882,6 +911,76 @@ func runTestMatcherMultiQueue() error {
 		return errors.TraceNew("unexpected compartment queue count")
 	}
 
+	// Test: priority
+
+	q = newAnnouncementMultiQueue()
+
+	var commonCompartmentIDs []ID
+	numCompartmentIDs := 10
+	for i := 0; i < numCompartmentIDs; i++ {
+		commonCompartmentID, _ := MakeID()
+		commonCompartmentIDs = append(
+			commonCompartmentIDs, commonCompartmentID)
+	}
+
+	priorityProxyID, _ := MakeID()
+	nonPriorityProxyID, _ := MakeID()
+
+	ctx, cancel := context.WithDeadline(
+		context.Background(), time.Now().Add(10*time.Minute))
+	defer cancel()
+
+	numEntries := 10000
+	for i := 0; i < numEntries; i++ {
+		// Enqueue every other announcement as a priority
+		isPriority := i%2 == 0
+		proxyID := priorityProxyID
+		if !isPriority {
+			proxyID = nonPriorityProxyID
+		}
+		err := q.enqueue(
+			&announcementEntry{
+				ctx: ctx,
+				announcement: &MatchAnnouncement{
+					ProxyID: proxyID,
+					Properties: MatchProperties{
+						IsPriority: isPriority,
+						CommonCompartmentIDs: []ID{
+							commonCompartmentIDs[prng.Intn(numCompartmentIDs)]},
+						NATType: NATTypeUnknown,
+					}}})
+		if err != nil {
+			return errors.Trace(err)
+		}
+	}
+
+	iter = q.startMatching(true, commonCompartmentIDs)
+	for i := 0; i < numEntries; i++ {
+		match, isPriority := iter.getNext()
+		if match == nil {
+			return errors.TraceNew("unexpected missing match")
+		}
+		// First half, and only first half, of matches is priority
+		expectPriority := i < numEntries/2
+		if isPriority != expectPriority {
+			return errors.TraceNew("unexpected isPriority")
+		}
+		expectedProxyID := priorityProxyID
+		if !expectPriority {
+			expectedProxyID = nonPriorityProxyID
+		}
+		if match.announcement.ProxyID != expectedProxyID {
+			return errors.TraceNew("unexpected ProxyID")
+		}
+		if !match.queueReference.dequeue() {
+			return errors.TraceNew("unexpected already dequeued")
+		}
+	}
+	match, _ = iter.getNext()
+	if match != nil {
+		return errors.TraceNew("unexpected  match")
+	}
+
 	return nil
 }
 

+ 36 - 10
psiphon/common/inproxy/proxy.go

@@ -58,6 +58,8 @@ type Proxy struct {
 
 	config                *ProxyConfig
 	activityUpdateWrapper *activityUpdateWrapper
+	lastConnectingClients int32
+	lastConnectedClients  int32
 
 	networkDiscoveryMutex     sync.Mutex
 	networkDiscoveryRunOnce   bool
@@ -202,14 +204,16 @@ func (p *Proxy) Run(ctx context.Context) {
 	// trip is awaited so that:
 	//
 	// - The first announce response will arrive with any new tactics,
-	//   avoiding a start up case where MaxClients initial, concurrent
-	//   announces all return with no-match and a tactics payload.
+	//   which may be applied before launching additions workers.
 	//
 	// - The first worker gets no announcement delay and is also guaranteed to
 	//   be the shared session establisher. Since the announcement delays are
 	//   applied _after_ waitToShareSession, it would otherwise be possible,
 	//   with a race of MaxClient initial, concurrent announces, for the
 	//   session establisher to be a different worker than the no-delay worker.
+	//
+	// The first worker is the only proxy worker which sets
+	// ProxyAnnounceRequest.CheckTactics.
 
 	signalFirstAnnounceCtx, signalFirstAnnounceDone :=
 		context.WithCancel(context.Background())
@@ -240,6 +244,9 @@ func (p *Proxy) Run(ctx context.Context) {
 	// for PeakUp/DownstreamBytesPerSecond. This is also a reasonable
 	// frequency for invoking the ActivityUpdater and updating UI widgets.
 
+	p.lastConnectingClients = 0
+	p.lastConnectedClients = 0
+
 	activityUpdatePeriod := 1 * time.Second
 	ticker := time.NewTicker(activityUpdatePeriod)
 	defer ticker.Stop()
@@ -285,11 +292,16 @@ func (p *Proxy) activityUpdate(period time.Duration) {
 	greaterThanSwapInt64(&p.peakBytesUp, bytesUp)
 	greaterThanSwapInt64(&p.peakBytesDown, bytesDown)
 
-	if connectingClients == 0 &&
-		connectedClients == 0 &&
+	clientsChanged := connectingClients != p.lastConnectingClients ||
+		connectedClients != p.lastConnectedClients
+
+	p.lastConnectingClients = connectingClients
+	p.lastConnectedClients = connectedClients
+
+	if !clientsChanged &&
 		bytesUp == 0 &&
 		bytesDown == 0 {
-		// Skip the activity callback on idle.
+		// Skip the activity callback on idle bytes or no change in client counts.
 		return
 	}
 
@@ -565,7 +577,8 @@ func (p *Proxy) proxyOneClient(
 	// returned in the proxy announcment response are associated and stored
 	// with the original network ID.
 
-	metrics, tacticsNetworkID, err := p.getMetrics(webRTCCoordinator)
+	metrics, tacticsNetworkID, err := p.getMetrics(
+		brokerCoordinator, webRTCCoordinator)
 	if err != nil {
 		return backOff, errors.Trace(err)
 	}
@@ -611,6 +624,10 @@ func (p *Proxy) proxyOneClient(
 	}
 	p.nextAnnounceMutex.Unlock()
 
+	// Only the first worker, which has signalAnnounceDone configured, checks
+	// for tactics.
+	checkTactics := signalAnnounceDone != nil
+
 	// A proxy ID is implicitly sent with requests; it's the proxy's session
 	// public key.
 	//
@@ -624,6 +641,7 @@ func (p *Proxy) proxyOneClient(
 		&ProxyAnnounceRequest{
 			PersonalCompartmentIDs: personalCompartmentIDs,
 			Metrics:                metrics,
+			CheckTactics:           checkTactics,
 		})
 	if logAnnounce() {
 		p.config.Logger.WithTraceFields(common.LogFields{
@@ -644,7 +662,9 @@ func (p *Proxy) proxyOneClient(
 		// discovery but proceed with handling the proxy announcement
 		// response as there may still be a match.
 
-		if p.config.HandleTacticsPayload(tacticsNetworkID, announceResponse.TacticsPayload) {
+		if p.config.HandleTacticsPayload(
+			tacticsNetworkID, announceResponse.TacticsPayload) {
+
 			p.resetNetworkDiscovery()
 		}
 	}
@@ -962,7 +982,9 @@ func (p *Proxy) proxyOneClient(
 	return backOff, err
 }
 
-func (p *Proxy) getMetrics(webRTCCoordinator WebRTCDialCoordinator) (*ProxyMetrics, string, error) {
+func (p *Proxy) getMetrics(
+	brokerCoordinator BrokerDialCoordinator,
+	webRTCCoordinator WebRTCDialCoordinator) (*ProxyMetrics, string, error) {
 
 	// tacticsNetworkID records the exact network ID that corresponds to the
 	// tactics tag sent in the base parameters, and is used when applying any
@@ -972,13 +994,17 @@ func (p *Proxy) getMetrics(webRTCCoordinator WebRTCDialCoordinator) (*ProxyMetri
 		return nil, "", errors.Trace(err)
 	}
 
-	packedBaseParams, err := protocol.EncodePackedAPIParameters(baseParams)
+	apiParams := common.APIParameters{}
+	apiParams.Add(baseParams)
+	apiParams.Add(common.APIParameters(brokerCoordinator.MetricsForBrokerRequests()))
+
+	packedParams, err := protocol.EncodePackedAPIParameters(apiParams)
 	if err != nil {
 		return nil, "", errors.Trace(err)
 	}
 
 	return &ProxyMetrics{
-		BaseAPIParameters:             packedBaseParams,
+		BaseAPIParameters:             packedParams,
 		ProxyProtocolVersion:          proxyProtocolVersion,
 		NATType:                       webRTCCoordinator.NATType(),
 		PortMappingTypes:              webRTCCoordinator.PortMappingTypes(),

+ 1 - 2
psiphon/common/inproxy/session_test.go

@@ -347,7 +347,7 @@ func runTestSessions() error {
 
 	request = roundTripper.MakeRequest()
 
-	response, err = unknownInitiatorSessions.RoundTrip(
+	_, err = unknownInitiatorSessions.RoundTrip(
 		ctx,
 		roundTripper,
 		responderPublicKey,
@@ -674,7 +674,6 @@ func runTestNoise() error {
 		return errors.Trace(err)
 	}
 
-	receivedPayload = nil
 	receivedPayload, _, _, err = initiatorHandshake.ReadMessage(nil, responderMsg)
 	if err != nil {
 		return errors.Trace(err)

+ 21 - 12
psiphon/common/packetman/packetman.go

@@ -249,12 +249,15 @@ func (spec *compiledSpec) apply(interceptedPacket gopacket.Packet) ([][]byte, er
 		tmpDataOffset := transformedTCP.DataOffset
 		tmpChecksum := transformedTCP.Checksum
 
-		gopacket.SerializeLayers(
+		err = gopacket.SerializeLayers(
 			buffer,
 			options,
 			serializableNetworkLayer,
 			&transformedTCP,
 			payload)
+		if err != nil {
+			return nil, errors.Trace(err)
+		}
 
 		// In the first SerializeLayers call, all IP and TCP length and checksums
 		// are recalculated and set to the correct values with transformations
@@ -268,13 +271,19 @@ func (spec *compiledSpec) apply(interceptedPacket gopacket.Packet) ([][]byte, er
 		if setCalculatedField {
 			transformedTCP.DataOffset = tmpDataOffset
 			transformedTCP.Checksum = tmpChecksum
-			buffer.Clear()
-			gopacket.SerializeLayers(
+			err = buffer.Clear()
+			if err != nil {
+				return nil, errors.Trace(err)
+			}
+			err = gopacket.SerializeLayers(
 				buffer,
 				gopacket.SerializeOptions{FixLengths: fixLengths, ComputeChecksums: computeChecksums},
 				serializableNetworkLayer,
 				&transformedTCP,
 				payload)
+			if err != nil {
+				return nil, errors.Trace(err)
+			}
 		}
 
 		packets[i] = buffer.Bytes()
@@ -388,15 +397,15 @@ func (t *transformationTCPFlags) apply(tcp *layers.TCP, _ *gopacket.Payload) {
 		flags = t.flags
 	}
 
-	tcp.FIN = strings.Index(t.flags, "F") != -1
-	tcp.SYN = strings.Index(t.flags, "S") != -1
-	tcp.RST = strings.Index(t.flags, "R") != -1
-	tcp.PSH = strings.Index(t.flags, "P") != -1
-	tcp.ACK = strings.Index(t.flags, "A") != -1
-	tcp.URG = strings.Index(t.flags, "U") != -1
-	tcp.ECE = strings.Index(t.flags, "E") != -1
-	tcp.CWR = strings.Index(t.flags, "C") != -1
-	tcp.NS = strings.Index(t.flags, "N") != -1
+	tcp.FIN = strings.Contains(flags, "F")
+	tcp.SYN = strings.Contains(flags, "S")
+	tcp.RST = strings.Contains(flags, "R")
+	tcp.PSH = strings.Contains(flags, "P")
+	tcp.ACK = strings.Contains(flags, "A")
+	tcp.URG = strings.Contains(flags, "U")
+	tcp.ECE = strings.Contains(flags, "E")
+	tcp.CWR = strings.Contains(flags, "C")
+	tcp.NS = strings.Contains(flags, "N")
 }
 
 type transformationTCPField struct {

+ 40 - 3
psiphon/common/parameters/parameters.go

@@ -372,6 +372,10 @@ const (
 	SteeringIPCacheMaxEntries                          = "SteeringIPCacheMaxEntries"
 	SteeringIPProbability                              = "SteeringIPProbability"
 	ServerDiscoveryStrategy                            = "ServerDiscoveryStrategy"
+	FrontedHTTPClientReplayDialParametersTTL           = "FrontedHTTPClientReplayDialParametersTTL"
+	FrontedHTTPClientReplayUpdateFrequency             = "FrontedHTTPClientReplayUpdateFrequency"
+	FrontedHTTPClientReplayDialParametersProbability   = "FrontedHTTPClientReplayDialParametersProbability"
+	FrontedHTTPClientReplayRetainFailedProbability     = "FrontedHTTPClientReplayRetainFailedProbability"
 	InproxyAllowProxy                                  = "InproxyAllowProxy"
 	InproxyAllowClient                                 = "InproxyAllowClient"
 	InproxyAllowDomainFrontedDestinations              = "InproxyAllowDomainFrontedDestinations"
@@ -397,6 +401,8 @@ const (
 	InproxyBrokerMatcherOfferLimitEntryCount           = "InproxyBrokerMatcherOfferLimitEntryCount"
 	InproxyBrokerMatcherOfferRateLimitQuantity         = "InproxyBrokerMatcherOfferRateLimitQuantity"
 	InproxyBrokerMatcherOfferRateLimitInterval         = "InproxyBrokerMatcherOfferRateLimitInterval"
+	InproxyBrokerMatcherPrioritizeProxiesProbability   = "InproxyBrokerMatcherPrioritizeProxiesProbability"
+	InproxyBrokerMatcherPrioritizeProxiesFilter        = "InproxyBrokerMatcherPrioritizeProxiesFilter"
 	InproxyBrokerProxyAnnounceTimeout                  = "InproxyBrokerProxyAnnounceTimeout"
 	InproxyBrokerClientOfferTimeout                    = "InproxyBrokerClientOfferTimeout"
 	InproxyBrokerClientOfferPersonalTimeout            = "InproxyBrokerClientOfferPersonalTimeout"
@@ -876,6 +882,11 @@ var defaultParameters = map[string]struct {
 
 	ServerDiscoveryStrategy: {value: "", flags: serverSideOnly},
 
+	FrontedHTTPClientReplayDialParametersTTL:         {value: 24 * time.Hour, minimum: time.Duration(0)},
+	FrontedHTTPClientReplayUpdateFrequency:           {value: 5 * time.Minute, minimum: time.Duration(0)},
+	FrontedHTTPClientReplayDialParametersProbability: {value: 1.0, minimum: 0.0},
+	FrontedHTTPClientReplayRetainFailedProbability:   {value: 0.5, minimum: 0.0},
+
 	// For inproxy tactics, there is no proxyOnly flag, since Psiphon apps may
 	// run both clients and inproxy proxies.
 	//
@@ -907,6 +918,8 @@ var defaultParameters = map[string]struct {
 	InproxyBrokerMatcherOfferLimitEntryCount:           {value: 10, minimum: 0, flags: serverSideOnly},
 	InproxyBrokerMatcherOfferRateLimitQuantity:         {value: 50, minimum: 0, flags: serverSideOnly},
 	InproxyBrokerMatcherOfferRateLimitInterval:         {value: 1 * time.Minute, minimum: time.Duration(0), flags: serverSideOnly},
+	InproxyBrokerMatcherPrioritizeProxiesProbability:   {value: 1.0, minimum: 0.0},
+	InproxyBrokerMatcherPrioritizeProxiesFilter:        {value: KeyStrings{}},
 	InproxyBrokerProxyAnnounceTimeout:                  {value: 2 * time.Minute, minimum: time.Duration(0), flags: serverSideOnly},
 	InproxyBrokerClientOfferTimeout:                    {value: 10 * time.Second, minimum: time.Duration(0), flags: serverSideOnly},
 	InproxyBrokerClientOfferPersonalTimeout:            {value: 5 * time.Second, minimum: time.Duration(0), flags: serverSideOnly},
@@ -955,8 +968,8 @@ var defaultParameters = map[string]struct {
 	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)},
+	InproxyClientDialRateLimitQuantity:                 {value: 2, minimum: 0},
+	InproxyClientDialRateLimitInterval:                 {value: 500 * time.Millisecond, minimum: time.Duration(0)},
 	InproxyClientNoMatchFailoverProbability:            {value: 0.5, minimum: 0.0},
 	InproxyClientNoMatchFailoverPersonalProbability:    {value: 1.0, minimum: 0.0},
 	InproxyFrontingProviderClientMaxRequestTimeouts:    {value: KeyDurations{}},
@@ -1226,6 +1239,15 @@ func (p *Parameters) Set(
 	}
 	inproxyAllCommonCompartmentIDs, _ := inproxyAllCommonCompartmentIDsValue.([]string)
 
+	// Special case: skip validation of transforms.Specs on the client side,
+	// since the regen operations may be slow. transforms.Specs are still
+	// validated on the server side, before being sent to clients. If a
+	// client's transforms.Spec is somehow corrupted, the tunnel dial
+	// applying the transform will error out -- transforms.Specs.Validate
+	// simply invokes the same apply operations.
+
+	validateTransformSpecs := serverSide
+
 	for i := 0; i < len(applyParameters); i++ {
 
 		count := 0
@@ -1418,6 +1440,11 @@ func (p *Parameters) Set(
 					return nil, errors.Trace(err)
 				}
 			case transforms.Specs:
+
+				if !validateTransformSpecs {
+					break
+				}
+
 				prefixMode := false
 				if name == OSSHPrefixSpecs || name == ServerOSSHPrefixSpecs {
 					prefixMode = true
@@ -1674,7 +1701,10 @@ func (p ParametersAccessor) IsNil() bool {
 // where memory footprint is a concern, and where the ParametersAccessor is
 // not immediately going out of scope. After Close is called, all other
 // ParametersAccessor functions will panic if called.
-func (p ParametersAccessor) Close() {
+//
+// Limitation: since ParametersAccessor is typically passed by value, this
+// Close call only impacts the immediate copy.
+func (p *ParametersAccessor) Close() {
 	p.snapshot = nil
 }
 
@@ -1937,6 +1967,13 @@ func (p ParametersAccessor) KeyStrings(name, key string) []string {
 	return value[key]
 }
 
+// KeyStringsValue returns a complete KeyStrings parameter value.
+func (p ParametersAccessor) KeyStringsValue(name string) KeyStrings {
+	value := KeyStrings{}
+	p.snapshot.getValue(name, &value)
+	return value
+}
+
 // KeyDurations returns a KeyDurations parameter value, with string durations
 // converted to time.Duration.
 func (p ParametersAccessor) KeyDurations(name string) map[string]time.Duration {

+ 4 - 0
psiphon/common/parameters/parameters_test.go

@@ -135,6 +135,10 @@ func TestGetDefaultParameters(t *testing.T) {
 					t.Fatalf("KeyStrings returned %+v expected %+v", g, strings)
 				}
 			}
+			g := p.Get().KeyStringsValue(name)
+			if !reflect.DeepEqual(v, g) {
+				t.Fatalf("KeyStrings returned %+v expected %+v", g, v)
+			}
 		case KeyDurations:
 			g := p.Get().KeyDurations(name)
 			durations := make(map[string]time.Duration)

+ 2 - 2
psiphon/common/prng/prng.go

@@ -198,7 +198,7 @@ func (p *PRNG) Int63() int64 {
 // Uint64 is equivilent to math/rand.Uint64.
 func (p *PRNG) Uint64() uint64 {
 	var b [8]byte
-	p.Read(b[:])
+	_, _ = p.Read(b[:])
 	return binary.BigEndian.Uint64(b[:])
 }
 
@@ -300,7 +300,7 @@ func (p *PRNG) RangeUint32(min, max uint32) uint32 {
 // Bytes returns a new slice containing length random bytes.
 func (p *PRNG) Bytes(length int) []byte {
 	b := make([]byte, length)
-	p.Read(b)
+	_, _ = p.Read(b)
 	return b
 }
 

+ 4 - 5
psiphon/common/quic/obfuscator.go

@@ -270,7 +270,7 @@ func (conn *ObfuscatedPacketConn) Close() error {
 	if conn.isServer {
 
 		// Interrupt any blocked writes.
-		conn.PacketConn.SetWriteDeadline(time.Now())
+		_ = conn.PacketConn.SetWriteDeadline(time.Now())
 
 		close(conn.stopBroadcast)
 		conn.runWaitGroup.Wait()
@@ -568,7 +568,6 @@ func (conn *ObfuscatedPacketConn) readPacket(
 				firstFlowPacket = true
 			} else {
 				isObfuscated = mode.isObfuscated
-				isIETF = mode.isIETF
 			}
 			mode.lastPacketTime = lastPacketTime
 
@@ -636,7 +635,7 @@ func (conn *ObfuscatedPacketConn) readPacket(
 				// There's a possible race condition between the two instances of locking
 				// peerModesMutex: the client might redial in the meantime. Check that the
 				// mode state is unchanged from when the lock was last held.
-				if !ok || mode.isObfuscated != true || mode.isIETF != false ||
+				if !ok || !mode.isObfuscated || mode.isIETF ||
 					mode.lastPacketTime != lastPacketTime {
 					conn.peerModesMutex.Unlock()
 					return n, oobn, flags, addr, true, newTemporaryNetError(
@@ -764,7 +763,7 @@ func (conn *ObfuscatedPacketConn) writePacket(
 			}
 
 			nonce := buffer[0:NONCE_SIZE]
-			conn.noncePRNG.Read(nonce)
+			_, _ = conn.noncePRNG.Read(nonce)
 
 			// This transform may reduce the entropy of the nonce, which increases
 			// the chance of nonce reuse. However, this chacha20 encryption is for
@@ -782,7 +781,7 @@ func (conn *ObfuscatedPacketConn) writePacket(
 			buffer[NONCE_SIZE] = uint8(paddingLen)
 
 			padding := buffer[(NONCE_SIZE + 1) : (NONCE_SIZE+1)+paddingLen]
-			conn.paddingPRNG.Read(padding)
+			_, _ = conn.paddingPRNG.Read(padding)
 
 			copy(buffer[(NONCE_SIZE+1)+paddingLen:], p)
 			dataLen := (NONCE_SIZE + 1) + paddingLen + n

+ 4 - 1
psiphon/common/resolver/resolver.go

@@ -1523,7 +1523,10 @@ func performDNSQuery(
 	startTime := time.Now()
 
 	// Send the DNS request
-	dnsConn.WriteMsg(request)
+	err := dnsConn.WriteMsg(request)
+	if err != nil {
+		return nil, nil, -1, errors.Trace(err)
+	}
 
 	// Read and process the DNS response
 	var IPs []net.IP

+ 1 - 1
psiphon/common/resolver/resolver_test.go

@@ -647,7 +647,7 @@ func runTestResolver() error {
 
 	cancelFunc()
 
-	IPs, err = resolver.ResolveIP(ctx, networkID, params, exampleDomain)
+	_, err = resolver.ResolveIP(ctx, networkID, params, exampleDomain)
 	if err == nil {
 		return errors.TraceNew("unexpected success")
 	}

+ 147 - 37
psiphon/common/tactics/tactics.go

@@ -157,6 +157,7 @@ import (
 	"io/ioutil"
 	"net/http"
 	"sort"
+	"strings"
 	"time"
 
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
@@ -164,6 +165,7 @@ import (
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/obfuscator"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
+	lrucache "github.com/cognusion/go-cache-lru"
 	"golang.org/x/crypto/nacl/box"
 )
 
@@ -189,6 +191,7 @@ const (
 	AGGREGATION_MINIMUM                = "Minimum"
 	AGGREGATION_MAXIMUM                = "Maximum"
 	AGGREGATION_MEDIAN                 = "Median"
+	PAYLOAD_CACHE_SIZE                 = 1024
 )
 
 var (
@@ -250,6 +253,9 @@ type Server struct {
 	logger                common.Logger
 	logFieldFormatter     common.APIParameterLogFieldFormatter
 	apiParameterValidator common.APIParameterValidator
+
+	cachedTacticsData *lrucache.Cache
+	filterMatches     []bool
 }
 
 const (
@@ -442,6 +448,8 @@ func NewServer(
 		logger:                logger,
 		logFieldFormatter:     logFieldFormatter,
 		apiParameterValidator: apiParameterValidator,
+		cachedTacticsData: lrucache.NewWithLRU(
+			lrucache.NoExpiration, 1*time.Minute, PAYLOAD_CACHE_SIZE),
 	}
 
 	server.ReloadableFile = common.NewReloadableFile(
@@ -467,6 +475,18 @@ func NewServer(
 			server.DefaultTactics = newServer.DefaultTactics
 			server.FilteredTactics = newServer.FilteredTactics
 
+			// Any cached, merged tactics data is flushed when the
+			// configuration changes.
+			//
+			// A single filterMatches, used in getTactics, is allocated here
+			// to avoid allocating a slice per getTactics call.
+			//
+			// Server.ReloadableFile.RLock/RUnlock is the mutex for accessing
+			// these and other Server fields.
+
+			server.cachedTacticsData.Flush()
+			server.filterMatches = make([]bool, len(server.FilteredTactics))
+
 			server.initLookups()
 
 			server.loaded = true
@@ -730,6 +750,8 @@ func (server *Server) GetFilterGeoIPScope(geoIPData common.GeoIPData) int {
 //
 // Elements of the returned Payload, e.g., tactics parameters, will point to
 // data in DefaultTactics and FilteredTactics and must not be modifed.
+//
+// Callers must not mutate returned tactics data, which is cached.
 func (server *Server) GetTacticsPayload(
 	geoIPData common.GeoIPData,
 	apiParams common.APIParameters) (*Payload, error) {
@@ -737,22 +759,17 @@ func (server *Server) GetTacticsPayload(
 	// includeServerSideOnly is false: server-side only parameters are not
 	// used by the client, so including them wastes space and unnecessarily
 	// exposes the values.
-	tactics, err := server.GetTactics(false, geoIPData, apiParams)
+	tacticsData, err := server.getTactics(false, geoIPData, apiParams)
 	if err != nil {
 		return nil, errors.Trace(err)
 	}
 
-	if tactics == nil {
+	if tacticsData == nil {
 		return nil, nil
 	}
 
-	marshaledTactics, tag, err := marshalTactics(tactics)
-	if err != nil {
-		return nil, errors.Trace(err)
-	}
-
 	payload := &Payload{
-		Tag: tag,
+		Tag: tacticsData.tag,
 	}
 
 	// New clients should always send STORED_TACTICS_TAG_PARAMETER_NAME. When they have no
@@ -777,57 +794,70 @@ func (server *Server) GetTacticsPayload(
 	}
 
 	if sendPayloadTactics {
-		payload.Tactics = marshaledTactics
+		payload.Tactics = tacticsData.payload
 	}
 
 	return payload, nil
 }
 
-func marshalTactics(tactics *Tactics) ([]byte, string, error) {
-	marshaledTactics, err := json.Marshal(tactics)
-	if err != nil {
-		return nil, "", errors.Trace(err)
-	}
-
-	// MD5 hash is used solely as a data checksum and not for any security purpose.
-	digest := md5.Sum(marshaledTactics)
-	tag := hex.EncodeToString(digest[:])
-
-	return marshaledTactics, tag, nil
-}
-
 // GetTacticsWithTag returns a GetTactics value along with the associated tag value.
+//
+// Callers must not mutate returned tactics data, which is cached.
 func (server *Server) GetTacticsWithTag(
 	includeServerSideOnly bool,
 	geoIPData common.GeoIPData,
 	apiParams common.APIParameters) (*Tactics, string, error) {
 
-	tactics, err := server.GetTactics(
+	tacticsData, err := server.getTactics(
 		includeServerSideOnly, geoIPData, apiParams)
 	if err != nil {
 		return nil, "", errors.Trace(err)
 	}
 
-	if tactics == nil {
+	if tacticsData == nil {
 		return nil, "", nil
 	}
 
-	_, tag, err := marshalTactics(tactics)
+	return tacticsData.tactics, tacticsData.tag, nil
+}
+
+// tacticsData is cached tactics data, including the merged Tactics object,
+// the JSON marshaled paylod, and hashed tag.
+type tacticsData struct {
+	tactics *Tactics
+	payload []byte
+	tag     string
+}
+
+func newTacticsData(tactics *Tactics) (*tacticsData, error) {
+
+	payload, err := json.Marshal(tactics)
 	if err != nil {
-		return nil, "", errors.Trace(err)
+		return nil, errors.Trace(err)
 	}
 
-	return tactics, tag, nil
+	// MD5 hash is used solely as a data checksum and not for any security
+	// purpose.
+	digest := md5.Sum(payload)
+	tag := hex.EncodeToString(digest[:])
+
+	return &tacticsData{
+		tactics: tactics,
+		payload: payload,
+		tag:     tag,
+	}, nil
 }
 
 // GetTactics assembles and returns tactics data for a client with the
 // specified GeoIP, API parameter, and speed test attributes.
 //
 // The tactics return value may be nil.
-func (server *Server) GetTactics(
+//
+// Callers must not mutate returned tactics data, which is cached.
+func (server *Server) getTactics(
 	includeServerSideOnly bool,
 	geoIPData common.GeoIPData,
-	apiParams common.APIParameters) (*Tactics, error) {
+	apiParams common.APIParameters) (*tacticsData, error) {
 
 	server.ReloadableFile.RLock()
 	defer server.ReloadableFile.RUnlock()
@@ -837,11 +867,19 @@ func (server *Server) GetTactics(
 		return nil, nil
 	}
 
-	tactics := server.DefaultTactics.clone(includeServerSideOnly)
+	// Two passes are performed, one to get the list of matching filters, and
+	// then, if no merged tactics data is found for that filter match set,
+	// another pass to merge all the tactics parameters.
 
 	var aggregatedValues map[string]int
+	filterMatchCount := 0
 
-	for _, filteredTactics := range server.FilteredTactics {
+	// Use the preallocated slice to avoid an allocation per getTactics call.
+	filterMatches := server.filterMatches
+
+	for filterIndex, filteredTactics := range server.FilteredTactics {
+
+		filterMatches[filterIndex] = false
 
 		if len(filteredTactics.Filter.Regions) > 0 {
 			if filteredTactics.Filter.regionLookup != nil {
@@ -944,15 +982,76 @@ func (server *Server) GetTactics(
 			}
 		}
 
-		tactics.merge(includeServerSideOnly, &filteredTactics.Tactics)
+		filterMatchCount += 1
+		filterMatches[filterIndex] = true
+
+		// Continue to check for more matches. Last matching tactics filter
+		// has priority for any field.
+	}
+
+	// For any filter match set, the merged tactics parameters are the same,
+	// so the resulting merge is cached, along with the JSON encoding of the
+	// payload and hash tag. This cache reduces, for repeated tactics
+	// requests, heavy allocations from the JSON marshal and CPU load from
+	// both the marshal and hashing the marshal result.
+	//
+	// getCacheKey still allocates a strings.Builder buffer.
+	//
+	// TODO: log cache metrics; similar to what is done in
+	// psiphon/server.ServerTacticsParametersCache.GetMetrics.
+
+	cacheKey := getCacheKey(includeServerSideOnly, filterMatchCount > 0, filterMatches)
 
-		// Continue to apply more matches. Last matching tactics has priority for any field.
+	cacheValue, ok := server.cachedTacticsData.Get(cacheKey)
+	if ok {
+		return cacheValue.(*tacticsData), nil
+	}
+
+	tactics := server.DefaultTactics.clone(includeServerSideOnly)
+	if filterMatchCount > 0 {
+		for filterIndex, filteredTactics := range server.FilteredTactics {
+			if filterMatches[filterIndex] {
+				tactics.merge(includeServerSideOnly, &filteredTactics.Tactics)
+			}
+		}
 	}
 
 	// See Tactics.Probability doc comment.
 	tactics.Probability = 1.0
 
-	return tactics, nil
+	tacticsData, err := newTacticsData(tactics)
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+
+	server.cachedTacticsData.Set(cacheKey, tacticsData, 0)
+
+	return tacticsData, nil
+}
+
+func getCacheKey(
+	includeServerSideOnly bool, hasFilterMatches bool, filterMatches []bool) string {
+
+	prefix := "0-"
+	if includeServerSideOnly {
+		prefix = "1-"
+	}
+
+	// hasFilterMatches allows for skipping the strings.Builder setup and loop
+	// entirely.
+	if !hasFilterMatches {
+		return prefix
+	}
+
+	var b strings.Builder
+	_, _ = b.WriteString(prefix)
+	for filterIndex, match := range filterMatches {
+		if match {
+			fmt.Fprintf(&b, "%x-", filterIndex)
+		}
+	}
+
+	return b.String()
 }
 
 // TODO: refactor this copy of psiphon/server.getStringRequestParam into common?
@@ -1162,7 +1261,13 @@ func (server *Server) handleSpeedTestRequest(
 	}
 
 	w.WriteHeader(http.StatusOK)
-	w.Write(response)
+	_, err = w.Write(response)
+	if err != nil {
+		server.logger.WithTraceFields(
+			common.LogFields{"error": err}).Warning("failed to write response")
+		common.TerminateHTTPConnection(w, r)
+		return
+	}
 }
 
 func (server *Server) handleTacticsRequest(
@@ -1234,8 +1339,13 @@ func (server *Server) handleTacticsRequest(
 	}
 
 	w.WriteHeader(http.StatusOK)
-	w.Write(boxedResponse)
-
+	_, err = w.Write(boxedResponse)
+	if err != nil {
+		server.logger.WithTraceFields(
+			common.LogFields{"error": err}).Warning("failed to write response")
+		common.TerminateHTTPConnection(w, r)
+		return
+	}
 	// Log a metric.
 
 	logFields := server.logFieldFormatter(geoIPData, apiParams)

+ 73 - 0
psiphon/common/tactics/tactics_test.go

@@ -93,6 +93,16 @@ func TestTactics(t *testing.T) {
             }
           }
         },
+        {
+          "Filter" : {
+            "APIParameters" : {"client_platform" : ["P2"], "client_version": ["V2"]}
+          },
+          "Tactics" : {
+            "Parameters" : {
+              "ConnectionWorkerPoolSize" : 1
+            }
+          }
+        },
         {
           "Filter" : {
             "Regions": ["R2"]
@@ -323,6 +333,26 @@ func TestTactics(t *testing.T) {
 		}
 	}
 
+	// Helper to check server-side cachedTacticsData state
+
+	checkServerCache := func(cacheEntryFilterMatches ...[]bool) {
+
+		cacheItems := server.cachedTacticsData.Items()
+		if len(cacheItems) != len(cacheEntryFilterMatches) {
+			t.Fatalf("Unexpected cachedTacticsData size: %v", len(cacheItems))
+		}
+
+		for _, filterMatches := range cacheEntryFilterMatches {
+			includeServerSizeOnly := false
+			hasFilterMatches := true
+			cacheKey := getCacheKey(includeServerSizeOnly, hasFilterMatches, filterMatches)
+			_, ok := server.cachedTacticsData.Get(cacheKey)
+			if !ok {
+				t.Fatalf("Unexpected missing cachedTacticsData entry: %v", filterMatches)
+			}
+		}
+	}
+
 	// Initial tactics request; will also run a speed test
 
 	// Request should complete in < 1 second
@@ -352,6 +382,10 @@ func TestTactics(t *testing.T) {
 
 	checkParameters(initialFetchTacticsRecord)
 
+	// Server should be caching tactics data for tactics matching first two
+	// filters.
+	checkServerCache([]bool{true, true, false, false, false})
+
 	// There should now be cached local tactics
 
 	storedTacticsRecord, err := UseStoredTactics(storer, networkID)
@@ -434,6 +468,9 @@ func TestTactics(t *testing.T) {
 
 	checkParameters(fetchTacticsRecord)
 
+	// Server cache should be the same
+	checkServerCache([]bool{true, true, false, false, false})
+
 	// Modify tactics configuration to change payload
 
 	tacticsConnectionWorkerPoolSize = 6
@@ -474,6 +511,9 @@ func TestTactics(t *testing.T) {
 		t.Fatalf("Server config failed to reload")
 	}
 
+	// Server cache should be flushed
+	checkServerCache()
+
 	// Next fetch should return a different payload
 
 	fetchTacticsRecord, err = FetchTactics(
@@ -509,6 +549,8 @@ func TestTactics(t *testing.T) {
 
 	checkParameters(fetchTacticsRecord)
 
+	checkServerCache([]bool{true, true, false, false, false})
+
 	// Exercise handshake transport of tactics
 
 	// Wait for tactics to expire; handshake should renew
@@ -563,6 +605,8 @@ func TestTactics(t *testing.T) {
 
 	checkParameters(handshakeTacticsRecord)
 
+	checkServerCache([]bool{true, true, false, false, false})
+
 	// Now there should be stored tactics
 
 	storedTacticsRecord, err = UseStoredTactics(storer, networkID)
@@ -596,6 +640,35 @@ func TestTactics(t *testing.T) {
 		t.Fatalf("unexpected stored tactics record")
 	}
 
+	// Server should cache a new entry for different filter matches
+
+	apiParams2 := common.APIParameters{
+		"client_platform": "P2",
+		"client_version":  "V2"}
+
+	fetchTacticsRecord, err = FetchTactics(
+		context.Background(),
+		params,
+		storer,
+		getNetworkID,
+		apiParams2,
+		endPointProtocol,
+		endPointRegion,
+		encodedRequestPublicKey,
+		encodedObfuscatedKey,
+		obfuscatedRoundTripper)
+	if err != nil {
+		t.Fatalf("FetchTactics failed: %s", err)
+	}
+
+	if fetchTacticsRecord == nil {
+		t.Fatalf("expected tactics record")
+	}
+
+	checkServerCache(
+		[]bool{true, true, false, false, false},
+		[]bool{false, false, true, false, false})
+
 	// Exercise speed test sample truncation
 
 	maxSamples := params.Get().Int(parameters.SpeedTestMaxSampleCount)

+ 2 - 2
psiphon/common/utils.go

@@ -136,8 +136,8 @@ func TruncateTimestampToHour(timestamp string) string {
 func Compress(data []byte) []byte {
 	var compressedData bytes.Buffer
 	writer := zlib.NewWriter(&compressedData)
-	writer.Write(data)
-	writer.Close()
+	_, _ = writer.Write(data)
+	_ = writer.Close()
 	return compressedData.Bytes()
 }
 

+ 23 - 0
psiphon/config.go

@@ -971,6 +971,13 @@ type Config struct {
 	SteeringIPCacheMaxEntries *int
 	SteeringIPProbability     *float64
 
+	// FrontedHTTPClientReplayDialParametersTTL and other FrontedHTTPClient
+	// fields are for testing purposes only.
+	FrontedHTTPClientReplayDialParametersTTLSeconds  *int
+	FrontedHTTPClientReplayUpdateFrequencySeconds    *int
+	FrontedHTTPClientReplayDialParametersProbability *float64
+	FrontedHTTPClientReplayRetainFailedProbability   *float64
+
 	// The following in-proxy fields are for testing purposes only.
 	InproxyAllowProxy                                       *bool
 	InproxyAllowClient                                      *bool
@@ -2425,6 +2432,22 @@ func (config *Config) makeConfigParameters() map[string]interface{} {
 		applyParameters[parameters.SteeringIPProbability] = *config.SteeringIPProbability
 	}
 
+	if config.FrontedHTTPClientReplayDialParametersTTLSeconds != nil {
+		applyParameters[parameters.FrontedHTTPClientReplayDialParametersTTL] = fmt.Sprintf("%ds", *config.FrontedHTTPClientReplayDialParametersTTLSeconds)
+	}
+
+	if config.FrontedHTTPClientReplayUpdateFrequencySeconds != nil {
+		applyParameters[parameters.FrontedHTTPClientReplayUpdateFrequency] = fmt.Sprintf("%ds", *config.FrontedHTTPClientReplayUpdateFrequencySeconds)
+	}
+
+	if config.FrontedHTTPClientReplayDialParametersProbability != nil {
+		applyParameters[parameters.FrontedHTTPClientReplayDialParametersProbability] = *config.FrontedHTTPClientReplayDialParametersProbability
+	}
+
+	if config.FrontedHTTPClientReplayRetainFailedProbability != nil {
+		applyParameters[parameters.FrontedHTTPClientReplayRetainFailedProbability] = *config.FrontedHTTPClientReplayRetainFailedProbability
+	}
+
 	if config.InproxyPersonalPairingConnectionWorkerPoolSize != 0 {
 		applyParameters[parameters.InproxyPersonalPairingConnectionWorkerPoolSize] = config.InproxyPersonalPairingConnectionWorkerPoolSize
 	}

+ 18 - 9
psiphon/controller.go

@@ -80,8 +80,6 @@ type Controller struct {
 	candidateServerEntries                  chan *candidateServerEntry
 	untunneledDialConfig                    *DialConfig
 	untunneledSplitTunnelClassifications    *lrucache.Cache
-	splitTunnelClassificationTTL            time.Duration
-	splitTunnelClassificationMaxEntries     int
 	signalFetchCommonRemoteServerList       chan struct{}
 	signalFetchObfuscatedServerLists        chan struct{}
 	signalDownloadUpgrade                   chan string
@@ -1039,8 +1037,7 @@ loop:
 
 			NoticeActiveTunnel(
 				connectedTunnel.dialParams.ServerEntry.GetDiagnosticID(),
-				connectedTunnel.dialParams.TunnelProtocol,
-				connectedTunnel.dialParams.ServerEntry.SupportsSSHAPIRequests())
+				connectedTunnel.dialParams.TunnelProtocol)
 
 			NoticeConnectedServerRegion(connectedTunnel.dialParams.ServerEntry.Region)
 
@@ -1172,7 +1169,11 @@ func (controller *Controller) registerTunnel(tunnel *Tunnel) bool {
 	// Connecting to a TargetServerEntry does not change the
 	// ranking.
 	if controller.config.TargetServerEntry == "" {
-		PromoteServerEntry(controller.config, tunnel.dialParams.ServerEntry.IpAddress)
+		err := PromoteServerEntry(controller.config, tunnel.dialParams.ServerEntry.IpAddress)
+		if err != nil {
+			NoticeWarning("PromoteServerEntry failed: %v", errors.Trace(err))
+			// Proceed with using tunnel
+		}
 	}
 
 	return true
@@ -1415,7 +1416,7 @@ func (controller *Controller) Dial(
 		// The server has indicated that the client should make a direct,
 		// untunneled dial. Cache the classification to avoid this round trip in
 		// the immediate future.
-		untunneledCache.Add(remoteAddr, true, lrucache.DefaultExpiration)
+		untunneledCache.Set(remoteAddr, true, lrucache.DefaultExpiration)
 	}
 
 	NoticeUntunneled(remoteAddr)
@@ -2256,7 +2257,7 @@ loop:
 			if err != nil {
 				NoticeError("failed to get next candidate: %v", errors.Trace(err))
 				controller.SignalComponentFailure()
-				break loop
+				return
 			}
 			if serverEntry == nil {
 				// Completed this iteration
@@ -2415,7 +2416,12 @@ loop:
 		}
 		timer.Stop()
 
-		iterator.Reset()
+		err := iterator.Reset()
+		if err != nil {
+			NoticeError("failed to reset iterator: %v", errors.Trace(err))
+			controller.SignalComponentFailure()
+			return
+		}
 	}
 }
 
@@ -2750,6 +2756,9 @@ loop:
 
 			// Clear the reference to this discarded tunnel and immediately run
 			// a garbage collection to reclaim its memory.
+			//
+			// Note: this assignment is flagged by github.com/gordonklaus/ineffassign,
+			// but should still have some effect on garbage collection?
 			tunnel = nil
 			DoGarbageCollection()
 		}
@@ -2830,6 +2839,7 @@ func (controller *Controller) runInproxyProxy() {
 
 	p := controller.config.GetParameters().Get()
 	allowProxy := p.Bool(parameters.InproxyAllowProxy)
+	activityNoticePeriod := p.Duration(parameters.InproxyProxyTotalActivityNoticePeriod)
 	p.Close()
 
 	// Running an upstream proxy is also an incompatible case.
@@ -2868,7 +2878,6 @@ func (controller *Controller) runInproxyProxy() {
 	// and formatting when debug logging is off.
 	debugLogging := controller.config.InproxyEnableWebRTCDebugLogging
 
-	activityNoticePeriod := p.Duration(parameters.InproxyProxyTotalActivityNoticePeriod)
 	var lastActivityNotice time.Time
 	var lastActivityConnectingClients, lastActivityConnectedClients int32
 	var lastActivityConnectingClientsTotal, lastActivityConnectedClientsTotal int32

+ 14 - 11
psiphon/dataStore.go

@@ -975,7 +975,10 @@ func (iterator *ServerEntryIterator) Next() (*protocol.ServerEntry, error) {
 		}
 
 		if doDeleteServerEntry {
-			deleteServerEntry(iterator.config, serverEntryID)
+			err := deleteServerEntry(iterator.config, serverEntryID)
+			NoticeWarning(
+				"ServerEntryIterator.Next: deleteServerEntry failed: %s",
+				errors.Trace(err))
 			continue
 		}
 
@@ -1039,12 +1042,12 @@ func (iterator *ServerEntryIterator) Next() (*protocol.ServerEntry, error) {
 					return errors.Trace(err)
 				}
 
-				serverEntries.put(serverEntryID, jsonServerEntryFields)
+				err = serverEntries.put(serverEntryID, jsonServerEntryFields)
 				if err != nil {
 					return errors.Trace(err)
 				}
 
-				serverEntryTags.put([]byte(serverEntryTag), serverEntryID)
+				err = serverEntryTags.put([]byte(serverEntryTag), serverEntryID)
 				if err != nil {
 					return errors.Trace(err)
 				}
@@ -1140,7 +1143,7 @@ func pruneServerEntry(config *Config, serverEntryTag string) error {
 		var serverEntry *protocol.ServerEntry
 		err := json.Unmarshal(serverEntryJson, &serverEntry)
 		if err != nil {
-			errors.Trace(err)
+			return errors.Trace(err)
 		}
 
 		// Only prune sufficiently old server entries. This mitigates the case where
@@ -1148,7 +1151,7 @@ func pruneServerEntry(config *Config, serverEntryTag string) error {
 		// being invalid/deleted.
 		serverEntryLocalTimestamp, err := time.Parse(time.RFC3339, serverEntry.LocalTimestamp)
 		if err != nil {
-			errors.Trace(err)
+			return errors.Trace(err)
 		}
 		if serverEntryLocalTimestamp.Add(minimumAgeForPruning).After(time.Now()) {
 			return nil
@@ -1162,7 +1165,7 @@ func pruneServerEntry(config *Config, serverEntryTag string) error {
 
 		err = serverEntryTags.delete(serverEntryTagBytes)
 		if err != nil {
-			errors.Trace(err)
+			return errors.Trace(err)
 		}
 
 		if doDeleteServerEntry {
@@ -1174,7 +1177,7 @@ func pruneServerEntry(config *Config, serverEntryTag string) error {
 				keyValues,
 				dialParameters)
 			if err != nil {
-				errors.Trace(err)
+				return errors.Trace(err)
 			}
 		}
 
@@ -1231,7 +1234,7 @@ func deleteServerEntry(config *Config, serverEntryID []byte) error {
 			keyValues,
 			dialParameters)
 		if err != nil {
-			errors.Trace(err)
+			return errors.Trace(err)
 		}
 
 		// Remove any tags pointing to the deleted server entry.
@@ -1259,7 +1262,7 @@ func deleteServerEntryHelper(
 
 	err := serverEntries.delete(serverEntryID)
 	if err != nil {
-		errors.Trace(err)
+		return errors.Trace(err)
 	}
 
 	affinityServerEntryID := keyValues.get(datastoreAffinityServerEntryIDKey)
@@ -1608,14 +1611,14 @@ func TakeOutUnreportedPersistentStats(config *Config) (map[string][][]byte, erro
 			for key, value := cursor.first(); key != nil; key, value = cursor.next() {
 
 				// Perform a test JSON unmarshaling. In case of data corruption or a bug,
-				// delete and skip the record.
+				// attempt to delete and skip the record.
 				var jsonData interface{}
 				err := json.Unmarshal(key, &jsonData)
 				if err != nil {
 					NoticeWarning(
 						"Invalid key in TakeOutUnreportedPersistentStats: %s: %s",
 						string(key), err)
-					bucket.delete(key)
+					_ = bucket.delete(key)
 					continue
 				}
 

+ 0 - 12
psiphon/dialParameters.go

@@ -1660,18 +1660,6 @@ func (dialParams *DialParameters) GetInproxyMetrics() common.LogFields {
 	return inproxyMetrics
 }
 
-func (dialParams *DialParameters) GetInproxyBrokerMetrics() common.LogFields {
-	inproxyMetrics := common.LogFields{}
-
-	if !dialParams.inproxyDialInitialized {
-		return inproxyMetrics
-	}
-
-	inproxyMetrics.Add(dialParams.inproxyBrokerDialParameters.GetBrokerMetrics())
-
-	return inproxyMetrics
-}
-
 func (dialParams *DialParameters) Succeeded() {
 
 	// When TTL is 0, don't store dial parameters.

+ 23 - 10
psiphon/feedback.go

@@ -119,13 +119,6 @@ func SendFeedback(ctx context.Context, config *Config, diagnostics, uploadPath s
 	defer resolver.Stop()
 	config.SetResolver(resolver)
 
-	// Get tactics, may update client parameters
-	p := config.GetParameters().Get()
-	timeout := p.Duration(parameters.FeedbackTacticsWaitPeriod)
-	p.Close()
-	getTacticsCtx, cancelFunc := context.WithTimeout(ctx, timeout)
-	defer cancelFunc()
-
 	// Limitation: GetTactics will fail silently if the datastore used for
 	// retrieving and storing tactics is opened by another process. This can
 	// be the case on Android and iOS where SendFeedback is invoked by the UI
@@ -142,10 +135,19 @@ 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, true)
+	doTactics := !config.DisableTactics
+	if doTactics {
+		// Get tactics, may update client parameters
+		p := config.GetParameters().Get()
+		timeout := p.Duration(parameters.FeedbackTacticsWaitPeriod)
+		p.Close()
+		getTacticsCtx, cancelFunc := context.WithTimeout(ctx, timeout)
+		GetTactics(getTacticsCtx, config, true)
+		cancelFunc()
+	}
 
 	// Get the latest client parameters
-	p = config.GetParameters().Get()
+	p := config.GetParameters().Get()
 	feedbackUploadMinRetryDelay := p.Duration(parameters.FeedbackUploadRetryMinDelaySeconds)
 	feedbackUploadMaxRetryDelay := p.Duration(parameters.FeedbackUploadRetryMaxDelaySeconds)
 	feedbackUploadTimeout := p.Duration(parameters.FeedbackUploadTimeoutSeconds)
@@ -204,15 +206,26 @@ func SendFeedback(ctx context.Context, config *Config, diagnostics, uploadPath s
 			feedbackUploadTimeout)
 		defer cancelFunc()
 
+		var dialConfig *DialConfig
+		if len(uploadURL.FrontingSpecs) == 0 {
+			// Must only set DialConfig if there are no fronting specs.
+			dialConfig = untunneledDialConfig
+		}
+
+		// Do not use device binder when domain fronting is used. See resolver
+		// comment above.
+		frontingUseDeviceBinder := false
+
 		payloadSecure := true
 		client, _, err := MakeUntunneledHTTPClient(
 			feedbackUploadCtx,
 			config,
-			untunneledDialConfig,
+			dialConfig,
 			uploadURL.SkipVerify,
 			config.DisableSystemRootCAs,
 			payloadSecure,
 			uploadURL.FrontingSpecs,
+			frontingUseDeviceBinder,
 			func(frontingProviderID string) {
 				NoticeInfo(
 					"SendFeedback: selected fronting provider %s for %s",

+ 202 - 1
psiphon/feedback_test.go

@@ -21,10 +21,27 @@ package psiphon
 
 import (
 	"context"
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/tls"
+	"crypto/x509"
+	"encoding/base64"
 	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
 	"io/ioutil"
+	"net/http"
+	"os"
 	"os/exec"
+	"strings"
 	"testing"
+	"time"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/values"
 )
 
 type Diagnostics struct {
@@ -41,7 +58,7 @@ type Diagnostics struct {
 	}
 }
 
-func TestFeedbackUpload(t *testing.T) {
+func TestFeedbackUploadRemote(t *testing.T) {
 	configFileContents, err := ioutil.ReadFile("controller_test.config")
 	if err != nil {
 		// Skip, don't fail, if config file is not present
@@ -99,3 +116,187 @@ func TestFeedbackUpload(t *testing.T) {
 		t.Fatalf("SendFeedback failed: %s", err)
 	}
 }
+
+func TestFeedbackUploadLocal(t *testing.T) {
+	t.Run("without fronting spec", func(t *testing.T) {
+		runTestFeedbackUploadLocal(t, false)
+	})
+	t.Run("with fronting spec", func(t *testing.T) {
+		runTestFeedbackUploadLocal(t, true)
+	})
+}
+
+func runTestFeedbackUploadLocal(t *testing.T, useFrontingSpecs bool) {
+
+	// Generate server keys
+
+	sk, err := rsa.GenerateKey(rand.Reader, 2048)
+	if err != nil {
+		t.Fatalf("error generating key: %s", err)
+	}
+
+	pubKeyBytes, err := x509.MarshalPKIXPublicKey(&sk.PublicKey)
+	if err != nil {
+		t.Fatalf("error marshaling public key: %s", err)
+	}
+
+	// Start local server that will receive feedback upload
+
+	mux := http.NewServeMux()
+
+	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+		_, err := io.ReadAll(r.Body)
+		if err != nil {
+			w.WriteHeader(http.StatusInternalServerError)
+		}
+		// TODO: verify HMAC and decrypt feedback
+	})
+
+	host := values.GetHostName()
+	certificate, privateKey, _, err := common.GenerateWebServerCertificate(host)
+	if err != nil {
+		t.Fatalf("common.GenerateWebServerCertificate failed: %v", err)
+	}
+
+	tlsCertificate, err := tls.X509KeyPair([]byte(certificate), []byte(privateKey))
+	if err != nil {
+		t.Fatalf("tls.X509KeyPair failed: %v", err)
+	}
+
+	serverConfig := &tls.Config{
+		Certificates: []tls.Certificate{tlsCertificate},
+		NextProtos:   []string{"http/1.1"},
+		MinVersion:   tls.VersionTLS10,
+	}
+
+	listener, err := tls.Listen("tcp", "127.0.0.1:0", serverConfig)
+	if err != nil {
+		t.Fatalf("net.Listen failed %v", err)
+	}
+	defer listener.Close()
+
+	s := &http.Server{
+		Addr:    listener.Addr().String(),
+		Handler: mux,
+	}
+	serverErrors := make(chan error)
+	defer func() {
+		err := s.Shutdown(context.Background())
+		if err != nil {
+			t.Fatalf("error shutting down server: %s", err)
+		}
+		err = <-serverErrors
+		if err != nil {
+			t.Fatalf("error running server: %s", err)
+		}
+	}()
+
+	go func() {
+		err := s.Serve(listener)
+		if !errors.Is(err, http.ErrServerClosed) {
+			serverErrors <- err
+		}
+		close(serverErrors)
+	}()
+
+	// Setup client
+
+	networkID := fmt.Sprintf("WIFI-%s", time.Now().String())
+
+	clientConfigJSON := fmt.Sprintf(`
+    {
+        "ClientPlatform" : "Android_10_com.test.app",
+        "ClientVersion" : "0",
+
+        "SponsorId" : "0000000000000000",
+        "PropagationChannelId" : "0000000000000000",
+        "DeviceLocation" : "gzzzz",
+        "DeviceRegion" : "US",
+        "DisableRemoteServerListFetcher" : true,
+        "EnableFeedbackUpload" : true,
+        "DisableTactics" : true,
+        "FeedbackEncryptionPublicKey" : "%s",
+        "NetworkID" : "%s"
+    }`,
+		base64.StdEncoding.EncodeToString(pubKeyBytes),
+		networkID)
+
+	config, err := LoadConfig([]byte(clientConfigJSON))
+	if err != nil {
+		t.Fatalf("error processing configuration file: %s", err)
+	}
+
+	testDataDirName, err := os.MkdirTemp("", "psiphon-feedback-test")
+	if err != nil {
+		t.Fatalf("TempDir failed: %s", err)
+	}
+	defer os.RemoveAll(testDataDirName)
+
+	config.DataRootDirectory = testDataDirName
+
+	address := listener.Addr().String()
+	addressRegex := strings.ReplaceAll(address, ".", "\\.")
+	url := fmt.Sprintf("https://%s", address)
+
+	var frontingSpecs parameters.FrontingSpecs
+	if useFrontingSpecs {
+		frontingSpecs = parameters.FrontingSpecs{
+			{
+				FrontingProviderID: prng.HexString(8),
+				Addresses:          []string{addressRegex},
+				DisableSNI:         prng.FlipCoin(),
+				SkipVerify:         true,
+				Host:               host,
+			},
+		}
+	}
+
+	config.FeedbackUploadURLs = parameters.TransferURLs{
+		{
+			URL:                 base64.StdEncoding.EncodeToString([]byte(url)),
+			SkipVerify:          true,
+			OnlyAfterAttempts:   0,
+			B64EncodedPublicKey: base64.StdEncoding.EncodeToString(pubKeyBytes),
+			RequestHeaders:      map[string]string{},
+			FrontingSpecs:       frontingSpecs,
+		},
+	}
+
+	err = config.Commit(true)
+	if err != nil {
+		t.Fatalf("error committing configuration file: %s", err)
+	}
+
+	err = OpenDataStore(config)
+	if err != nil {
+		t.Fatalf("OpenDataStore failed: %s", err)
+	}
+	defer CloseDataStore()
+
+	// Construct feedback data
+
+	diagnostics := Diagnostics{}
+	diagnostics.Feedback.Message.Text = "Test feedback from feedback_test.go"
+	diagnostics.Metadata.Id = "0000000000000000"
+	diagnostics.Metadata.Platform = "android"
+	diagnostics.Metadata.Version = 4
+
+	diagnosticData, err := json.Marshal(diagnostics)
+	if err != nil {
+		t.Fatalf("Marshal failed: %s", err)
+	}
+
+	// Upload feedback
+
+	err = SendFeedback(context.Background(), config, string(diagnosticData), "/upload_path")
+	if err != nil {
+		t.Fatalf("SendFeedback failed: %s", err)
+	}
+
+	// Upload feedback again to exercise replay
+
+	err = SendFeedback(context.Background(), config, string(diagnosticData), "/upload_path")
+	if err != nil {
+		t.Fatalf("SendFeedback failed: %s", err)
+	}
+}

+ 449 - 0
psiphon/frontedHTTP.go

@@ -0,0 +1,449 @@
+package psiphon
+
+import (
+	"bytes"
+	"context"
+	"encoding/binary"
+	"fmt"
+	"io"
+	"net/http"
+	"sync"
+	"time"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
+	"github.com/cespare/xxhash"
+)
+
+// frontedHTTPClientInstance contains the fronted HTTP dial parameters required
+// to create a net/http.Client, which is configured to use domain fronting.
+// frontedHTTPClientInstance implements HTTP client dial replay.
+type frontedHTTPClientInstance struct {
+	frontedHTTPDialParameters     *frontedHTTPDialParameters
+	networkID                     string
+	replayEnabled                 bool
+	replayRetainFailedProbability float64
+	replayUpdateFrequency         time.Duration
+
+	mutex           sync.Mutex
+	lastStoreReplay time.Time
+}
+
+// newFrontedHTTPClientInstance creates a new frontedHTTPClientInstance.
+// newFrontedHTTPClientInstance does not perform any network operations; the
+// new frontedHTTPClientInstance is initialized when used for a round
+// trip.
+func newFrontedHTTPClientInstance(
+	config *Config,
+	tunnel *Tunnel,
+	frontingSpecs parameters.FrontingSpecs,
+	selectedFrontingProviderID func(string),
+	useDeviceBinder,
+	skipVerify,
+	disableSystemRootCAs,
+	payloadSecure bool,
+) (*frontedHTTPClientInstance, error) {
+
+	if len(frontingSpecs) == 0 {
+		return nil, errors.TraceNew("no fronting specs")
+	}
+
+	// This function duplicates some code from NewInproxyBrokerClientInstance.
+	//
+	// TODO: merge common functionality?
+
+	p := config.GetParameters().Get()
+	defer p.Close()
+
+	// Shuffle fronting specs, for random load balancing. Fronting specs with
+	// available dial parameter replay data are preferred.
+
+	permutedIndexes := prng.Perm(len(frontingSpecs))
+	shuffledFrontingSpecs := make(parameters.FrontingSpecs, len(frontingSpecs))
+	for i, index := range permutedIndexes {
+		shuffledFrontingSpecs[i] = frontingSpecs[index]
+	}
+	frontingSpecs = shuffledFrontingSpecs
+
+	// Replay fronted HTTP dial parameters.
+
+	var spec *parameters.FrontingSpec
+	var dialParams *frontedHTTPDialParameters
+
+	// Replay is disabled when the TTL, FrontedHTTPClientReplayDialParametersTTL,
+	// is 0.
+	now := time.Now()
+	ttl := p.Duration(parameters.FrontedHTTPClientReplayDialParametersTTL)
+	networkID := config.GetNetworkID()
+
+	// Replay is disabled if there is an active tunnel.
+	replayEnabled := tunnel == nil &&
+		ttl > 0 &&
+		!config.DisableReplay &&
+		prng.FlipWeightedCoin(p.Float(parameters.FrontedHTTPClientReplayDialParametersProbability))
+
+	if replayEnabled {
+		selectFirstCandidate := false
+		var err error
+		spec, dialParams, err =
+			SelectCandidateWithNetworkReplayParameters[parameters.FrontingSpec, frontedHTTPDialParameters](
+				networkID,
+				selectFirstCandidate,
+				frontingSpecs,
+				func(spec *parameters.FrontingSpec) string { return spec.FrontingProviderID },
+				func(spec *parameters.FrontingSpec, dialParams *frontedHTTPDialParameters) bool {
+					// Replay the successful fronting spec, if present, by
+					// comparing its hash with that of the candidate.
+					return dialParams.LastUsedTimestamp.After(now.Add(-ttl)) &&
+						bytes.Equal(dialParams.LastUsedFrontingSpecHash, hashFrontingSpec(spec))
+				})
+		if err != nil {
+			NoticeWarning("SelectCandidateWithNetworkReplayParameters failed: %v", errors.Trace(err))
+			// Continue without replay
+		}
+	}
+
+	// Select the first fronting spec in the shuffle when replay is not enabled
+	// or in case SelectCandidateWithNetworkReplayParameters fails.
+	if spec == nil {
+		spec = frontingSpecs[0]
+	}
+
+	// Generate new fronted HTTP dial parameters if not replaying. Later,
+	// isReplay is used to report the replay metric.
+
+	isReplay := dialParams != nil
+
+	if !isReplay {
+		var err error
+		dialParams, err = makeFrontedHTTPDialParameters(
+			config,
+			p,
+			tunnel,
+			spec,
+			selectedFrontingProviderID,
+			useDeviceBinder,
+			skipVerify,
+			disableSystemRootCAs,
+			payloadSecure)
+		if err != nil {
+			return nil, errors.Trace(err)
+		}
+	} else {
+		err := dialParams.prepareDialConfigs(
+			config,
+			p,
+			isReplay,
+			tunnel,
+			useDeviceBinder,
+			skipVerify,
+			disableSystemRootCAs,
+			payloadSecure)
+		if err != nil {
+			return nil, errors.Trace(err)
+		}
+	}
+
+	return &frontedHTTPClientInstance{
+		networkID:                 networkID,
+		frontedHTTPDialParameters: dialParams,
+		replayEnabled:             replayEnabled,
+
+		replayRetainFailedProbability: p.Float(parameters.FrontedHTTPClientReplayRetainFailedProbability),
+		replayUpdateFrequency:         p.Duration(parameters.FrontedHTTPClientReplayUpdateFrequency),
+	}, nil
+}
+
+// RoundTrip implements the http.RoundTripper interface. RoundTrip makes a
+// domain fronted request to the meek server.
+//
+// Resources are cleaned up when the response body is closed.
+func (f *frontedHTTPClientInstance) RoundTrip(request *http.Request) (*http.Response, error) {
+
+	// This function duplicates some code from InproxyBrokerRoundTripper.RoundTrip,
+	// which has a more thorough implementation.
+	//
+	// TODO: merge implementations or common functionality?
+
+	// Use MeekConn to domain front requests.
+	conn, err := DialMeek(
+		request.Context(),
+		f.frontedHTTPDialParameters.FrontedMeekDialParameters.meekConfig,
+		f.frontedHTTPDialParameters.FrontedMeekDialParameters.dialConfig)
+	if err != nil {
+		if request.Context().Err() != context.Canceled {
+			// DialMeek performs an initial TLS handshake. Clear replay
+			// parameters on error, excluding a cancelled context as
+			// happens on shutdown.
+			f.frontedHTTPClientRoundTripperFailed()
+		}
+		return nil, errors.Trace(err)
+	}
+
+	response, err := conn.RoundTrip(request)
+	if err != nil {
+		if request.Context().Err() != context.Canceled {
+			// Clear replay parameters on other round trip errors, including
+			// TLS failures and client-side timeouts, but excluding a cancelled
+			// context as happens on shutdown.
+			f.frontedHTTPClientRoundTripperFailed()
+		}
+		return nil, errors.Trace(err)
+	}
+
+	// Do not read the response body into memory all at once because it may
+	// be large. Instead allow the caller to stream the response.
+	body := newMeekHTTPResponseReadCloser(conn, response.Body)
+
+	// Clear replay parameters if there are any errors while reading from the
+	// response body.
+	response.Body = newFrontedHTTPClientResponseReadCloser(f, body)
+
+	// HTTP status codes other than 200 may indicate success depending on the
+	// semantics of the operation. E.g., resumeable downloads are considered
+	// successful if the HTTP server returns 200, 206, 304, 412, or 416.
+	//
+	// TODO: have the caller determine success and failure cases because this
+	// is not always determined by the HTTP status code; e.g., HTTP server
+	// returns 200 but payload signature check fails.
+	if response.StatusCode == http.StatusOK ||
+		response.StatusCode == http.StatusPartialContent ||
+		response.StatusCode == http.StatusRequestedRangeNotSatisfiable ||
+		response.StatusCode == http.StatusPreconditionFailed ||
+		response.StatusCode == http.StatusNotModified {
+
+		f.frontedHTTPClientRoundTripperSucceeded()
+	} else {
+		// TODO: do not clear replay parameters on temporary round tripper
+		// failures, see InproxyBrokerRoundTripper.RoundTrip.
+		f.frontedHTTPClientRoundTripperFailed()
+	}
+
+	return response, nil
+}
+
+// meekHTTPResponseReadCloser wraps an http.Response.Body received over a
+// frontedHTTPClientInstance in RoundTrip and exposes an io.ReadCloser.
+// Replay parameters are cleared if there are any errors while reading from
+// the response body.
+type frontedHTTPClientResponseReadCloser struct {
+	client       *frontedHTTPClientInstance
+	responseBody io.ReadCloser
+}
+
+// newFrontedHTTPClientResponseReadCloser creates a frontedHTTPClientResponseReadCloser.
+func newFrontedHTTPClientResponseReadCloser(
+	client *frontedHTTPClientInstance,
+	responseBody io.ReadCloser) *frontedHTTPClientResponseReadCloser {
+
+	return &frontedHTTPClientResponseReadCloser{
+		client:       client,
+		responseBody: responseBody,
+	}
+}
+
+// Read implements the io.Reader interface.
+func (f *frontedHTTPClientResponseReadCloser) Read(p []byte) (n int, err error) {
+	n, err = f.responseBody.Read(p)
+	if err != nil {
+		f.client.frontedHTTPClientRoundTripperFailed()
+	}
+	return n, err
+}
+
+// Read implements the io.Closer interface.
+func (f *frontedHTTPClientResponseReadCloser) Close() error {
+	return f.responseBody.Close()
+}
+
+// frontedHTTPClientRoundTripperSucceeded stores the current dial parameters
+// for replay.
+func (f *frontedHTTPClientInstance) frontedHTTPClientRoundTripperSucceeded() {
+
+	// Note: duplicates code in BrokerClientRoundTripperSucceeded.
+
+	f.mutex.Lock()
+	defer f.mutex.Unlock()
+
+	now := time.Now()
+	if f.replayEnabled && now.Sub(f.lastStoreReplay) > f.replayUpdateFrequency {
+		f.frontedHTTPDialParameters.LastUsedTimestamp = time.Now()
+
+		replayID := f.frontedHTTPDialParameters.FrontedMeekDialParameters.FrontingProviderID
+
+		err := SetNetworkReplayParameters[frontedHTTPDialParameters](
+			f.networkID, replayID, f.frontedHTTPDialParameters)
+		if err != nil {
+			NoticeWarning("SetNetworkReplayParameters failed: %v", errors.Trace(err))
+			// Continue without persisting replay changes.
+		} else {
+			f.lastStoreReplay = now
+		}
+	}
+}
+
+// frontedHTTPClientRoundTripperFailed clears replay parameters.
+func (f *frontedHTTPClientInstance) frontedHTTPClientRoundTripperFailed() {
+
+	// Note: duplicates code in BrokerClientRoundTripperFailed.
+
+	f.mutex.Lock()
+	defer f.mutex.Unlock()
+	// Delete any persistent replay dial parameters. Unlike with the success
+	// case, consecutive, repeated deletes shouldn't write to storage, so
+	// they are not avoided.
+
+	if f.replayEnabled &&
+		!prng.FlipWeightedCoin(f.replayRetainFailedProbability) {
+
+		// Limitation: there's a race condition with multiple
+		// frontedHTTPClientInstances writing to the replay datastore, such as
+		// in the case where there's a feedback upload running concurrently
+		// with a server list download; this delete could potentially clobber a
+		// concurrent fresh replay store after a success.
+		//
+		// TODO: add an additional storage key distinguisher for each instance?
+
+		replayID := f.frontedHTTPDialParameters.FrontedMeekDialParameters.FrontingProviderID
+
+		err := DeleteNetworkReplayParameters[frontedHTTPDialParameters](
+			f.networkID, replayID)
+		if err != nil {
+			NoticeWarning("DeleteNetworkReplayParameters failed: %v", errors.Trace(err))
+			// Continue without resetting replay.
+		}
+	}
+}
+
+// hashFrontingSpec hashes the fronting spec. The hash is used to detect when
+// fronting spec tactics have changed.
+func hashFrontingSpec(spec *parameters.FrontingSpec) []byte {
+	var hash [8]byte
+	binary.BigEndian.PutUint64(
+		hash[:],
+		uint64(xxhash.Sum64String(fmt.Sprintf("%+v", spec))))
+	return hash[:]
+}
+
+// frontedHTTPDialParameters represents a selected fronting transport and dial
+// parameters.
+//
+// frontedHTTPDialParameters is used to configure dialers; as a persistent
+// record to store successful dial parameters for replay; and to report dial
+// stats in notices and Psiphon API calls.
+//
+// frontedHTTPDialParameters is similar to tunnel DialParameters, but is
+// specific to fronted HTTP. It should be used for all fronted HTTP dials,
+// apart from the tunnel DialParameters cases.
+type frontedHTTPDialParameters struct {
+	isReplay bool `json:"-"`
+
+	LastUsedTimestamp        time.Time
+	LastUsedFrontingSpecHash []byte
+
+	FrontedMeekDialParameters *FrontedMeekDialParameters
+}
+
+// makeFrontedHTTPDialParameters creates a new frontedHTTPDialParameters for
+// configuring a fronted HTTP client, including selecting a fronting transport
+// and all the various protocol attributes.
+//
+// payloadSecure must only be set if all HTTP plaintext payloads sent through
+// the returned net/http.Client will be wrapped in their own transport security
+// layer, which permits skipping of server certificate verification.
+func makeFrontedHTTPDialParameters(
+	config *Config,
+	p parameters.ParametersAccessor,
+	tunnel *Tunnel,
+	frontingSpec *parameters.FrontingSpec,
+	selectedFrontingProviderID func(string),
+	useDeviceBinder,
+	skipVerify,
+	disableSystemRootCAs,
+	payloadSecure bool) (*frontedHTTPDialParameters, error) {
+
+	currentTimestamp := time.Now()
+
+	dialParams := &frontedHTTPDialParameters{
+		LastUsedTimestamp:        currentTimestamp,
+		LastUsedFrontingSpecHash: hashFrontingSpec(frontingSpec),
+	}
+
+	var err error
+	dialParams.FrontedMeekDialParameters, err = makeFrontedMeekDialParameters(
+		config,
+		p,
+		tunnel,
+		parameters.FrontingSpecs{frontingSpec},
+		selectedFrontingProviderID,
+		useDeviceBinder,
+		skipVerify,
+		disableSystemRootCAs,
+		payloadSecure,
+	)
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+
+	// Initialize Dial/MeekConfigs to be passed to the corresponding dialers.
+
+	err = dialParams.prepareDialConfigs(
+		config,
+		p,
+		false,
+		tunnel,
+		skipVerify,
+		disableSystemRootCAs,
+		useDeviceBinder,
+		payloadSecure)
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+
+	return dialParams, nil
+}
+
+// prepareDialConfigs is called for both new and replayed dial parameters.
+func (dialParams *frontedHTTPDialParameters) prepareDialConfigs(
+	config *Config,
+	p parameters.ParametersAccessor,
+	isReplay bool,
+	tunnel *Tunnel,
+	useDeviceBinder,
+	skipVerify,
+	disableSystemRootCAs,
+	payloadSecure bool) error {
+
+	dialParams.isReplay = isReplay
+
+	if isReplay {
+
+		// Initialize Dial/MeekConfigs to be passed to the corresponding dialers.
+
+		err := dialParams.FrontedMeekDialParameters.prepareDialConfigs(
+			config, p, tunnel, nil, useDeviceBinder, skipVerify,
+			disableSystemRootCAs, payloadSecure)
+		if err != nil {
+			return errors.Trace(err)
+		}
+	}
+
+	return nil
+}
+
+// GetMetrics implements the common.MetricsSource interface and returns log
+// fields detailing the fronted HTTP dial parameters.
+func (dialParams *frontedHTTPDialParameters) GetMetrics() common.LogFields {
+
+	logFields := dialParams.FrontedMeekDialParameters.GetMetrics("")
+
+	isReplay := "0"
+	if dialParams.isReplay {
+		isReplay = "1"
+	}
+	logFields["is_replay"] = isReplay
+
+	return logFields
+}

+ 172 - 0
psiphon/frontedHTTP_test.go

@@ -0,0 +1,172 @@
+/*
+ * Copyright (c) 2024, Psiphon Inc.
+ * All rights reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+package psiphon
+
+import (
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/x509"
+	"encoding/base64"
+	"fmt"
+	"os"
+	"testing"
+	"time"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestFrontedHTTPClientInstance(t *testing.T) {
+
+	// Generate server keys
+
+	sk, err := rsa.GenerateKey(rand.Reader, 2048)
+	if err != nil {
+		t.Fatalf("error generating key: %s", err)
+	}
+
+	pubKeyBytes, err := x509.MarshalPKIXPublicKey(&sk.PublicKey)
+	if err != nil {
+		t.Fatalf("error marshaling public key: %s", err)
+	}
+
+	// Setup client
+
+	networkID := fmt.Sprintf("WIFI-%s", time.Now().String())
+
+	clientConfigJSON := fmt.Sprintf(`
+    {
+        "ClientPlatform" : "Android_10_com.test.app",
+        "ClientVersion" : "0",
+
+        "SponsorId" : "0000000000000000",
+        "PropagationChannelId" : "0000000000000000",
+        "DeviceLocation" : "gzzzz",
+        "DeviceRegion" : "US",
+        "DisableRemoteServerListFetcher" : true,
+        "EnableFeedbackUpload" : true,
+        "DisableTactics" : true,
+        "FeedbackEncryptionPublicKey" : "%s",
+        "NetworkID" : "%s"
+    }`,
+		base64.StdEncoding.EncodeToString(pubKeyBytes),
+		networkID)
+
+	config, err := LoadConfig([]byte(clientConfigJSON))
+	if err != nil {
+		t.Fatalf("error processing configuration file: %s", err)
+	}
+
+	testDataDirName, err := os.MkdirTemp("", "psiphon-feedback-test")
+	if err != nil {
+		t.Fatalf("TempDir failed: %s", err)
+	}
+	config.DataRootDirectory = testDataDirName
+
+	address := "example.org"
+	addressRegex := `[a-z0-9]{5,10}\.example\.org`
+	url := fmt.Sprintf("https://%s", address)
+
+	frontingSpecs := parameters.FrontingSpecs{
+		{
+			FrontingProviderID: prng.HexString(8),
+			Addresses:          []string{addressRegex},
+			DisableSNI:         prng.FlipCoin(),
+			SkipVerify:         true,
+			Host:               "example.org",
+		},
+	}
+
+	config.FeedbackUploadURLs = parameters.TransferURLs{
+		{
+			URL:                 base64.StdEncoding.EncodeToString([]byte(url)),
+			SkipVerify:          true,
+			OnlyAfterAttempts:   0,
+			B64EncodedPublicKey: base64.StdEncoding.EncodeToString(pubKeyBytes),
+			RequestHeaders:      map[string]string{},
+			FrontingSpecs:       frontingSpecs,
+		},
+	}
+
+	err = config.Commit(true)
+	if err != nil {
+		t.Fatalf("error committing configuration file: %s", err)
+	}
+
+	resolver := NewResolver(config, false)
+	defer resolver.Stop()
+	config.SetResolver(resolver)
+
+	err = OpenDataStore(config)
+	if err != nil {
+		t.Fatalf("OpenDataStore failed: %s", err)
+	}
+	defer CloseDataStore()
+
+	// Make fronted HTTP client instance
+
+	// TODO: test that replay is disabled when there is a tunnel
+	var tunnel *Tunnel = nil
+	useDeviceBinder := true
+	skipVerify := false
+	payloadSecure := true
+	client, err := newFrontedHTTPClientInstance(
+		config, tunnel, frontingSpecs, nil, useDeviceBinder, skipVerify, config.DisableSystemRootCAs, payloadSecure)
+	if err != nil {
+		t.Fatalf("newFrontedHTTPClientInstance failed: %s", err)
+	}
+	client.frontedHTTPClientRoundTripperSucceeded()
+
+	// Do replay
+
+	prevClient := client
+
+	client, err = newFrontedHTTPClientInstance(
+		config, tunnel, frontingSpecs, nil, useDeviceBinder, skipVerify, config.DisableSystemRootCAs, payloadSecure)
+	if err != nil {
+		t.Fatalf("newFrontedHTTPClientInstance failed: %s", err)
+	}
+
+	if !client.frontedHTTPDialParameters.isReplay {
+		t.Fatal("expected replay")
+	}
+
+	// Note: only exported FrontedHTTPDialParameters fields are stored for replay.
+	assert.EqualExportedValues(t, prevClient.frontedHTTPDialParameters, client.frontedHTTPDialParameters)
+
+	// Change network ID so there should be no replay.
+	config.NetworkID = fmt.Sprintf("CELLULAR-%s", time.Now().String())
+	err = config.Commit(true)
+	if err != nil {
+		t.Fatalf("error committing configuration file: %s", err)
+	}
+
+	client, err = newFrontedHTTPClientInstance(
+		config, tunnel, frontingSpecs, nil, useDeviceBinder, skipVerify,
+		config.DisableSystemRootCAs, payloadSecure)
+	if err != nil {
+		t.Fatalf("newFrontedHTTPClientInstance failed: %s", err)
+	}
+
+	if client.frontedHTTPDialParameters.isReplay {
+		t.Fatal("expected no replay")
+	}
+}

+ 528 - 0
psiphon/frontingDialParameters.go

@@ -0,0 +1,528 @@
+package psiphon
+
+import (
+	"context"
+	"net"
+	"net/http"
+	"strconv"
+	"sync/atomic"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/fragmentor"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/resolver"
+	utls "github.com/Psiphon-Labs/utls"
+	"golang.org/x/net/bpf"
+)
+
+// FrontedMeekDialParameters represents a selected fronting transport and all
+// the related protocol attributes, many chosen at random, for a fronted dial
+// attempt.
+//
+// FrontedMeekDialParameters is used:
+// - to configure dialers
+// - as a persistent record to store successful dial parameters for replay
+// - to report dial stats in notices and Psiphon API calls.
+//
+// FrontedMeekDialParameters is similar to tunnel DialParameters, but is
+// specific to fronted meek. It should be used for all fronted meek dials,
+// apart from the tunnel DialParameters cases.
+//
+// prepareDialConfigs must be called on any unmarshaled
+// FrontedMeekDialParameters. For example, when unmarshaled from a replay
+// record.
+//
+// resolvedIPAddress is set asynchronously, as it is not known until the dial
+// process has begun. The atomic.Value will contain a string, initialized to
+// "", and set to the resolved IP address once that part of the dial process
+// has completed.
+//
+// FrontedMeekDialParameters is not safe for concurrent use.
+type FrontedMeekDialParameters struct {
+	NetworkLatencyMultiplier float64
+
+	FrontingTransport string
+
+	DialAddress string
+
+	FrontingProviderID  string
+	FrontingDialAddress string
+	SNIServerName       string
+	TransformedHostName bool
+	VerifyServerName    string
+	VerifyPins          []string
+	HostHeader          string
+	resolvedIPAddress   atomic.Value `json:"-"`
+
+	TLSProfile               string
+	TLSVersion               string
+	RandomizedTLSProfileSeed *prng.Seed
+	NoDefaultTLSSessionID    bool
+	TLSFragmentClientHello   bool
+
+	SelectedUserAgent bool
+	UserAgent         string
+
+	BPFProgramName         string
+	BPFProgramInstructions []bpf.RawInstruction
+
+	FragmentorSeed *prng.Seed
+
+	ResolveParameters *resolver.ResolveParameters
+
+	dialConfig *DialConfig `json:"-"`
+	meekConfig *MeekConfig `json:"-"`
+}
+
+// makeFrontedMeekDialParameters creates a new FrontedMeekDialParameters for
+// configuring a fronted HTTP client, including selecting a fronting transport,
+// and all the various protocol attributes.
+//
+// payloadSecure must only be set if all HTTP plaintext payloads sent through
+// the returned net/http.Client will be wrapped in their own transport security
+// layer, which permits skipping of server certificate verification.
+func makeFrontedMeekDialParameters(
+	config *Config,
+	p parameters.ParametersAccessor,
+	tunnel *Tunnel,
+	frontingSpecs parameters.FrontingSpecs,
+	selectedFrontingProviderID func(string),
+	useDeviceBinder,
+	skipVerify,
+	disableSystemRootCAs,
+	payloadSecure bool) (*FrontedMeekDialParameters, error) {
+
+	// This function duplicates some code from MakeDialParameters. To simplify
+	// the logic, the Replay<Component> tactic flags for individual dial
+	// components are ignored.
+	//
+	// TODO: merge common functionality?
+
+	if !payloadSecure && (skipVerify || disableSystemRootCAs) {
+		return nil, errors.TraceNew("cannot skip certificate verification if payload insecure")
+	}
+
+	frontedMeekDialParams := FrontedMeekDialParameters{}
+
+	// Network latency multiplier
+
+	frontedMeekDialParams.NetworkLatencyMultiplier = prng.ExpFloat64Range(
+		p.Float(parameters.NetworkLatencyMultiplierMin),
+		p.Float(parameters.NetworkLatencyMultiplierMax),
+		p.Float(parameters.NetworkLatencyMultiplierLambda))
+
+	// Select fronting configuration
+
+	var err error
+
+	frontedMeekDialParams.FrontingProviderID,
+		frontedMeekDialParams.FrontingTransport,
+		frontedMeekDialParams.FrontingDialAddress,
+		frontedMeekDialParams.SNIServerName,
+		frontedMeekDialParams.VerifyServerName,
+		frontedMeekDialParams.VerifyPins,
+		frontedMeekDialParams.HostHeader,
+		err = frontingSpecs.SelectParameters()
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+
+	// At this time, the transport is limited to fronted HTTPS.
+	//
+	// As a future enhancement, allow HTTP in certain cases (e.g. the in-proxy
+	// broker case), skip selecting TLS tactics and select HTTP tactics such as
+	// HTTPTransformerParameters.
+
+	if frontedMeekDialParams.FrontingTransport == protocol.FRONTING_TRANSPORT_HTTP {
+		return nil, errors.TraceNew("unsupported fronting transport")
+	}
+
+	if selectedFrontingProviderID != nil {
+		selectedFrontingProviderID(frontedMeekDialParams.FrontingProviderID)
+	}
+
+	// FrontingSpec.Addresses may include a port; default to 443 if none.
+
+	if _, _, err := net.SplitHostPort(frontedMeekDialParams.FrontingDialAddress); err == nil {
+		frontedMeekDialParams.DialAddress = frontedMeekDialParams.FrontingDialAddress
+	} else {
+		frontedMeekDialParams.DialAddress = net.JoinHostPort(frontedMeekDialParams.FrontingDialAddress, "443")
+	}
+
+	// Determine and use the equivalent tunnel protocol for tactics
+	// selections. For example, for the broker transport FRONTED-HTTPS, use
+	// the tactics for FRONTED-MEEK-OSSH.
+
+	equivalentTunnelProtocol, err := protocol.EquivilentTunnelProtocol(frontedMeekDialParams.FrontingTransport)
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+
+	// SNI configuration
+	//
+	// For a FrontingSpec, an SNI value of "" indicates to disable/omit SNI, so
+	// never transform in that case.
+
+	if frontedMeekDialParams.SNIServerName != "" {
+		if p.WeightedCoinFlip(parameters.TransformHostNameProbability) {
+			frontedMeekDialParams.SNIServerName = selectHostName(equivalentTunnelProtocol, p)
+			frontedMeekDialParams.TransformedHostName = true
+		}
+	}
+
+	// TLS configuration
+	//
+	// In the in-proxy case, the requireTLS13 flag is set to true, and
+	// requireTLS12SessionTickets to false, in order to use only modern TLS
+	// fingerprints which should support HTTP/2 in the ALPN.
+	//
+	// TODO: TLS padding
+
+	requireTLS12SessionTickets :=
+		!protocol.TunnelProtocolUsesInproxy(equivalentTunnelProtocol) &&
+			protocol.TunnelProtocolRequiresTLS12SessionTickets(
+				equivalentTunnelProtocol)
+
+	requireTLS13Support :=
+		protocol.TunnelProtocolUsesInproxy(equivalentTunnelProtocol) ||
+			protocol.TunnelProtocolRequiresTLS13Support(equivalentTunnelProtocol)
+	isFronted := true
+	frontedMeekDialParams.TLSProfile,
+		frontedMeekDialParams.TLSVersion,
+		frontedMeekDialParams.RandomizedTLSProfileSeed,
+		err = SelectTLSProfile(requireTLS12SessionTickets, requireTLS13Support, isFronted, frontedMeekDialParams.FrontingProviderID, p)
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+
+	if frontedMeekDialParams.TLSProfile == "" && (requireTLS12SessionTickets || requireTLS13Support) {
+		return nil, errors.TraceNew("required TLS profile not found")
+	}
+
+	frontedMeekDialParams.NoDefaultTLSSessionID = p.WeightedCoinFlip(
+		parameters.NoDefaultTLSSessionIDProbability)
+
+	if frontedMeekDialParams.SNIServerName != "" && net.ParseIP(frontedMeekDialParams.SNIServerName) == nil {
+		tlsFragmentorLimitProtocols := p.TunnelProtocols(parameters.TLSFragmentClientHelloLimitProtocols)
+		if len(tlsFragmentorLimitProtocols) == 0 || common.Contains(tlsFragmentorLimitProtocols, equivalentTunnelProtocol) {
+			frontedMeekDialParams.TLSFragmentClientHello = p.WeightedCoinFlip(parameters.TLSFragmentClientHelloProbability)
+		}
+	}
+
+	// User Agent configuration
+
+	dialCustomHeaders := makeDialCustomHeaders(config, p)
+	frontedMeekDialParams.SelectedUserAgent, frontedMeekDialParams.UserAgent = selectUserAgentIfUnset(p, dialCustomHeaders)
+
+	// Resolver configuration
+	//
+	// The custom resolver is wired up only when there is a domain to be
+	// resolved; GetMetrics will log resolver metrics when the resolver is set.
+
+	if net.ParseIP(frontedMeekDialParams.DialAddress) == nil {
+
+		resolver := config.GetResolver()
+		if resolver == nil {
+			return nil, errors.TraceNew("missing resolver")
+		}
+
+		frontedMeekDialParams.ResolveParameters, err = resolver.MakeResolveParameters(
+			p, frontedMeekDialParams.FrontingProviderID, frontedMeekDialParams.DialAddress)
+		if err != nil {
+			return nil, errors.Trace(err)
+		}
+	}
+
+	if tunnel == nil {
+
+		// BPF configuration
+
+		if ClientBPFEnabled() &&
+			protocol.TunnelProtocolMayUseClientBPF(equivalentTunnelProtocol) {
+
+			if p.WeightedCoinFlip(parameters.BPFClientTCPProbability) {
+				frontedMeekDialParams.BPFProgramName = ""
+				frontedMeekDialParams.BPFProgramInstructions = nil
+				ok, name, rawInstructions := p.BPFProgram(parameters.BPFClientTCPProgram)
+				if ok {
+					frontedMeekDialParams.BPFProgramName = name
+					frontedMeekDialParams.BPFProgramInstructions = rawInstructions
+				}
+			}
+		}
+
+		// Fragmentor configuration
+
+		frontedMeekDialParams.FragmentorSeed, err = prng.NewSeed()
+		if err != nil {
+			return nil, errors.Trace(err)
+		}
+	}
+
+	// Initialize Dial/MeekConfigs to be passed to the corresponding dialers.
+
+	err = frontedMeekDialParams.prepareDialConfigs(
+		config, p, tunnel, dialCustomHeaders, useDeviceBinder, skipVerify,
+		disableSystemRootCAs, payloadSecure)
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+
+	return &frontedMeekDialParams, nil
+}
+
+// prepareDialConfigs is called for both new and replayed dial parameters.
+func (f *FrontedMeekDialParameters) prepareDialConfigs(
+	config *Config,
+	p parameters.ParametersAccessor,
+	tunnel *Tunnel,
+	dialCustomHeaders http.Header,
+	useDeviceBinder,
+	skipVerify,
+	disableSystemRootCAs,
+	payloadSecure bool) error {
+
+	if !payloadSecure && (skipVerify || disableSystemRootCAs) {
+		return errors.TraceNew("cannot skip certificate verification if payload insecure")
+	}
+
+	equivilentTunnelProtocol, err := protocol.EquivilentTunnelProtocol(f.FrontingTransport)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	// Custom headers and User Agent
+
+	if dialCustomHeaders == nil {
+		dialCustomHeaders = makeDialCustomHeaders(config, p)
+	}
+	if f.SelectedUserAgent {
+		dialCustomHeaders.Set("User-Agent", f.UserAgent)
+	}
+
+	// Fragmentor
+
+	fragmentorConfig := fragmentor.NewUpstreamConfig(
+		p, equivilentTunnelProtocol, f.FragmentorSeed)
+
+	// Resolver
+	//
+	// DialConfig.ResolveIP is required and called even when the destination
+	// is an IP address.
+
+	resolver := config.GetResolver()
+	if resolver == nil {
+		return errors.TraceNew("missing resolver")
+	}
+
+	// DialConfig
+
+	f.resolvedIPAddress.Store("")
+
+	var resolveIP func(context.Context, string) ([]net.IP, error)
+	if tunnel != nil {
+		tunneledDialer := func(_, addr string) (net.Conn, error) {
+			// Set alwaysTunneled to ensure the http.Client traffic is always tunneled,
+			// even when split tunnel mode is enabled.
+			conn, _, err := tunnel.DialTCPChannel(addr, true, nil)
+			return conn, errors.Trace(err)
+		}
+		f.dialConfig = &DialConfig{
+			DiagnosticID:                  f.FrontingProviderID,
+			TrustedCACertificatesFilename: config.TrustedCACertificatesFilename,
+			CustomDialer: func(_ context.Context, _, addr string) (net.Conn, error) {
+				return tunneledDialer("", addr)
+			},
+		}
+	} else {
+		resolveIP = func(ctx context.Context, hostname string) ([]net.IP, error) {
+			IPs, err := UntunneledResolveIP(
+				ctx, config, resolver, hostname, f.FrontingProviderID)
+			if err != nil {
+				return nil, errors.Trace(err)
+			}
+			return IPs, nil
+		}
+
+		var deviceBinder DeviceBinder
+		if useDeviceBinder {
+			deviceBinder = config.DeviceBinder
+		}
+
+		f.dialConfig = &DialConfig{
+			DiagnosticID:                  f.FrontingProviderID,
+			UpstreamProxyURL:              config.UpstreamProxyURL,
+			CustomHeaders:                 dialCustomHeaders,
+			BPFProgramInstructions:        f.BPFProgramInstructions,
+			TrustedCACertificatesFilename: config.TrustedCACertificatesFilename,
+			FragmentorConfig:              fragmentorConfig,
+			DeviceBinder:                  deviceBinder,
+			IPv6Synthesizer:               config.IPv6Synthesizer,
+			ResolveIP:                     resolveIP,
+			ResolvedIPCallback: func(IPAddress string) {
+				f.resolvedIPAddress.Store(IPAddress)
+			},
+		}
+	}
+
+	// MeekDialConfig
+
+	// Note: if MeekModeRelay or MeekModeObfuscatedRoundTrip are supported in the
+	// future, set MeekObfuscatorPaddingSeed.
+	var meekMode MeekMode = MeekModePlaintextRoundTrip
+	if payloadSecure {
+		meekMode = MeekModeWrappedPlaintextRoundTrip
+	}
+
+	addPsiphonFrontingHeader := false
+	if f.FrontingProviderID != "" {
+		addPsiphonFrontingHeader = common.Contains(
+			p.LabeledTunnelProtocols(
+				parameters.AddFrontingProviderPsiphonFrontingHeader,
+				f.FrontingProviderID),
+			equivilentTunnelProtocol)
+	}
+
+	f.meekConfig = &MeekConfig{
+		DiagnosticID:             f.FrontingProviderID,
+		Parameters:               config.GetParameters(),
+		Mode:                     meekMode,
+		DialAddress:              f.DialAddress,
+		TLSProfile:               f.TLSProfile,
+		TLSFragmentClientHello:   f.TLSFragmentClientHello,
+		NoDefaultTLSSessionID:    f.NoDefaultTLSSessionID,
+		RandomizedTLSProfileSeed: f.RandomizedTLSProfileSeed,
+		SNIServerName:            f.SNIServerName,
+		HostHeader:               f.HostHeader,
+		TransformedHostName:      f.TransformedHostName,
+		AddPsiphonFrontingHeader: addPsiphonFrontingHeader,
+		VerifyServerName:         f.VerifyServerName,
+		VerifyPins:               f.VerifyPins,
+		ClientTunnelProtocol:     equivilentTunnelProtocol,
+		NetworkLatencyMultiplier: f.NetworkLatencyMultiplier,
+		AdditionalHeaders:        config.MeekAdditionalHeaders,
+		// TODO: Change hard-coded session key be something like FrontingProviderID + BrokerID.
+		// This is necessary once longer-term TLS caches are added.
+		// The meek dial address, based on the fronting dial address returned by
+		// parameters.FrontingSpecs.SelectParameters has couple of issues. For some providers there's
+		// only a couple or even just one possible value, in other cases there are millions of possible values
+		// and cached values won't be used as often as they ought to be.
+		TLSClientSessionCache: common.WrapUtlsClientSessionCache(utls.NewLRUClientSessionCache(0), f.DialAddress),
+	}
+
+	if !skipVerify {
+		f.meekConfig.DisableSystemRootCAs = disableSystemRootCAs
+		if !f.meekConfig.DisableSystemRootCAs {
+			f.meekConfig.VerifyServerName = f.VerifyServerName
+			f.meekConfig.VerifyPins = f.VerifyPins
+		}
+	}
+
+	switch f.FrontingTransport {
+	case protocol.FRONTING_TRANSPORT_HTTPS:
+		f.meekConfig.UseHTTPS = true
+	case protocol.FRONTING_TRANSPORT_QUIC:
+		// TODO: configure QUIC tactics
+		f.meekConfig.UseQUIC = true
+	}
+
+	return nil
+}
+
+// GetMetrics returns log fields detailing the fronted meek dial parameters.
+// All log field names are prefixed with overridePrefix, when specified, which
+// also overrides any default prefixes.
+func (meekDialParameters *FrontedMeekDialParameters) GetMetrics(overridePrefix string) common.LogFields {
+
+	prefix := ""
+	meekPrefix := "meek_"
+
+	if overridePrefix != "" {
+		prefix = overridePrefix
+		meekPrefix = overridePrefix
+	}
+
+	logFields := make(common.LogFields)
+
+	logFields[prefix+"fronting_provider_id"] = meekDialParameters.FrontingProviderID
+
+	if meekDialParameters.DialAddress != "" {
+		logFields[meekPrefix+"dial_address"] = meekDialParameters.DialAddress
+	}
+
+	meekResolvedIPAddress := meekDialParameters.resolvedIPAddress.Load().(string)
+	if meekResolvedIPAddress != "" {
+		logFields[meekPrefix+"resolved_ip_address"] = meekResolvedIPAddress
+	}
+
+	if meekDialParameters.SNIServerName != "" {
+		logFields[meekPrefix+"sni_server_name"] = meekDialParameters.SNIServerName
+	}
+
+	if meekDialParameters.HostHeader != "" {
+		logFields[meekPrefix+"host_header"] = meekDialParameters.HostHeader
+	}
+
+	transformedHostName := "0"
+	if meekDialParameters.TransformedHostName {
+		transformedHostName = "1"
+	}
+	logFields[meekPrefix+"transformed_host_name"] = transformedHostName
+
+	if meekDialParameters.SelectedUserAgent {
+		logFields[prefix+"user_agent"] = meekDialParameters.UserAgent
+	}
+
+	if meekDialParameters.FrontingTransport == protocol.FRONTING_TRANSPORT_HTTPS {
+
+		if meekDialParameters.TLSProfile != "" {
+			logFields[prefix+"tls_profile"] = meekDialParameters.TLSProfile
+		}
+
+		if meekDialParameters.TLSVersion != "" {
+			logFields[prefix+"tls_version"] =
+				getTLSVersionForMetrics(meekDialParameters.TLSVersion, meekDialParameters.NoDefaultTLSSessionID)
+		}
+
+		tlsFragmented := "0"
+		if meekDialParameters.TLSFragmentClientHello {
+			tlsFragmented = "1"
+		}
+		logFields[prefix+"tls_fragmented"] = tlsFragmented
+	}
+
+	if meekDialParameters.BPFProgramName != "" {
+		logFields[prefix+"client_bpf"] = meekDialParameters.BPFProgramName
+	}
+
+	if meekDialParameters.ResolveParameters != nil {
+
+		// See comment for dialParams.ResolveParameters handling in
+		// getBaseAPIParameters.
+
+		if meekDialParameters.ResolveParameters.PreresolvedIPAddress != "" {
+			dialDomain, _, _ := net.SplitHostPort(meekDialParameters.meekConfig.DialAddress)
+			if meekDialParameters.ResolveParameters.PreresolvedDomain == dialDomain {
+				logFields[prefix+"dns_preresolved"] = meekDialParameters.ResolveParameters.PreresolvedIPAddress
+			}
+		}
+
+		if meekDialParameters.ResolveParameters.PreferAlternateDNSServer {
+			logFields[prefix+"dns_preferred"] = meekDialParameters.ResolveParameters.AlternateDNSServer
+		}
+
+		if meekDialParameters.ResolveParameters.ProtocolTransformName != "" {
+			logFields[prefix+"dns_transform"] = meekDialParameters.ResolveParameters.ProtocolTransformName
+		}
+
+		logFields[prefix+"dns_attempt"] = strconv.Itoa(
+			meekDialParameters.ResolveParameters.GetFirstAttemptWithAnswer())
+	}
+
+	// TODO: get fragmentor metrics, if any, from MeekConn.
+
+	return logFields
+}

+ 66 - 360
psiphon/inproxy.go

@@ -37,15 +37,11 @@ import (
 
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
-	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/fragmentor"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/inproxy"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
-	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/resolver"
-	utls "github.com/Psiphon-Labs/utls"
 	"github.com/cespare/xxhash"
-	"golang.org/x/net/bpf"
 )
 
 // InproxyBrokerClientManager manages an InproxyBrokerClientInstance, an
@@ -428,6 +424,8 @@ func NewInproxyBrokerClientInstance(
 				brokerSpecs,
 				func(spec *parameters.InproxyBrokerSpec) string { return spec.BrokerPublicKey },
 				func(spec *parameters.InproxyBrokerSpec, dialParams *InproxyBrokerDialParameters) bool {
+					// Replay the successful broker spec, if present, by
+					// comparing its hash with that of the candidate.
 					return dialParams.LastUsedTimestamp.After(now.Add(-ttl)) &&
 						bytes.Equal(dialParams.LastUsedBrokerSpecHash, hashBrokerSpec(spec))
 				})
@@ -448,6 +446,12 @@ func NewInproxyBrokerClientInstance(
 
 	isReplay := brokerDialParams != nil
 
+	// Handle legacy replay records by discarding replay when required fields
+	// are missing.
+	if isReplay && brokerDialParams.FrontedHTTPDialParameters == nil {
+		isReplay = false
+	}
+
 	if !isReplay {
 		brokerDialParams, err = MakeInproxyBrokerDialParameters(config, p, networkID, brokerSpec)
 		if err != nil {
@@ -455,7 +459,7 @@ func NewInproxyBrokerClientInstance(
 		}
 	} else {
 		brokerDialParams.brokerSpec = brokerSpec
-		err := brokerDialParams.prepareDialConfigs(config, p, networkID, true, nil)
+		err := brokerDialParams.prepareDialConfigs(config, p, true)
 		if err != nil {
 			return nil, errors.Trace(err)
 		}
@@ -536,7 +540,7 @@ func NewInproxyBrokerClientInstance(
 	// Adjust long-polling request timeouts to respect any maximum request
 	// timeout supported by the provider fronting the request.
 	maxRequestTimeout, ok := p.KeyDurations(
-		parameters.InproxyFrontingProviderClientMaxRequestTimeouts)[brokerDialParams.FrontingProviderID]
+		parameters.InproxyFrontingProviderClientMaxRequestTimeouts)[brokerDialParams.FrontedHTTPDialParameters.FrontingProviderID]
 	if ok && maxRequestTimeout > 0 {
 		if b.announceRequestTimeout > maxRequestTimeout {
 			b.announceRequestTimeout = maxRequestTimeout
@@ -857,7 +861,7 @@ func (b *InproxyBrokerClientInstance) BrokerClientRoundTripperSucceeded(roundTri
 
 	resolver := b.config.GetResolver()
 	if resolver != nil {
-		resolver.VerifyCacheExtension(b.brokerDialParams.FrontingDialAddress)
+		resolver.VerifyCacheExtension(b.brokerDialParams.FrontedHTTPDialParameters.FrontingDialAddress)
 	}
 }
 
@@ -983,6 +987,11 @@ func (b *InproxyBrokerClientInstance) BrokerClientNoMatch(roundTripper inproxy.R
 	}
 }
 
+// Implements the inproxy.BrokerDialCoordinator interface.
+func (b *InproxyBrokerClientInstance) MetricsForBrokerRequests() common.LogFields {
+	return b.brokerDialParams.GetMetricsForBrokerRequests()
+}
+
 // Implements the inproxy.BrokerDialCoordinator interface.
 func (b *InproxyBrokerClientInstance) AnnounceRequestTimeout() time.Duration {
 	return b.announceRequestTimeout
@@ -1049,39 +1058,7 @@ type InproxyBrokerDialParameters struct {
 	LastUsedTimestamp      time.Time
 	LastUsedBrokerSpecHash []byte
 
-	NetworkLatencyMultiplier float64
-
-	BrokerTransport string
-
-	DialAddress string
-
-	FrontingProviderID  string
-	FrontingDialAddress string
-	SNIServerName       string
-	TransformedHostName bool
-	VerifyServerName    string
-	VerifyPins          []string
-	HostHeader          string
-	ResolvedIPAddress   atomic.Value `json:"-"`
-
-	TLSProfile               string
-	TLSVersion               string
-	RandomizedTLSProfileSeed *prng.Seed
-	NoDefaultTLSSessionID    bool
-	TLSFragmentClientHello   bool
-
-	SelectedUserAgent bool
-	UserAgent         string
-
-	BPFProgramName         string
-	BPFProgramInstructions []bpf.RawInstruction
-
-	FragmentorSeed *prng.Seed
-
-	ResolveParameters *resolver.ResolveParameters
-
-	dialConfig *DialConfig `json:"-"`
-	meekConfig *MeekConfig `json:"-"`
+	FrontedHTTPDialParameters *FrontedMeekDialParameters
 }
 
 // MakeInproxyBrokerDialParameters creates a new InproxyBrokerDialParameters.
@@ -1091,163 +1068,50 @@ func MakeInproxyBrokerDialParameters(
 	networkID string,
 	brokerSpec *parameters.InproxyBrokerSpec) (*InproxyBrokerDialParameters, error) {
 
-	// This function duplicates some code from MakeDialParameters and
-	// makeFrontedHTTPClient. To simplify the logic, the Replay<Component>
-	// tactic flags for individual dial components are ignored.
-	//
-	// TODO: merge common functionality?
-
 	if config.UseUpstreamProxy() {
 		return nil, errors.TraceNew("upstream proxy unsupported")
 	}
 
 	currentTimestamp := time.Now()
 
-	var brokerDialParams *InproxyBrokerDialParameters
-
 	// Select new broker dial parameters
 
-	brokerDialParams = &InproxyBrokerDialParameters{
+	brokerDialParams := &InproxyBrokerDialParameters{
 		brokerSpec:             brokerSpec,
 		LastUsedTimestamp:      currentTimestamp,
 		LastUsedBrokerSpecHash: hashBrokerSpec(brokerSpec),
 	}
 
-	// Network latency multiplier
-
-	brokerDialParams.NetworkLatencyMultiplier = prng.ExpFloat64Range(
-		p.Float(parameters.NetworkLatencyMultiplierMin),
-		p.Float(parameters.NetworkLatencyMultiplierMax),
-		p.Float(parameters.NetworkLatencyMultiplierLambda))
-
-	// Select fronting configuration
-
-	var err error
-
-	brokerDialParams.FrontingProviderID,
-		brokerDialParams.BrokerTransport,
-		brokerDialParams.FrontingDialAddress,
-		brokerDialParams.SNIServerName,
-		brokerDialParams.VerifyServerName,
-		brokerDialParams.VerifyPins,
-		brokerDialParams.HostHeader,
-		err = brokerDialParams.brokerSpec.BrokerFrontingSpecs.SelectParameters()
-	if err != nil {
-		return nil, errors.Trace(err)
-	}
-
-	// At this time, the broker client, the transport is limited to fronted
-	// HTTPS.
+	// FrontedMeekDialParameters
 	//
-	// As a future enhancement, allow HTTP for the in-proxy broker case, skip
-	// selecting TLS tactics and select HTTP tactics such as
-	// HTTPTransformerParameters.
-
-	if brokerDialParams.BrokerTransport == protocol.FRONTING_TRANSPORT_HTTP {
-		return nil, errors.TraceNew("unsupported fronting transport")
-	}
-
-	// Determine and use the equivilent tunnel protocol for tactics
-	// selections. For example, for the broker transport FRONTED-HTTPS, use
-	// the tactics for FRONTED-MEEK-OSSH.
-
-	equivilentTunnelProtocol, err := protocol.EquivilentTunnelProtocol(brokerDialParams.BrokerTransport)
-	if err != nil {
-		return nil, errors.Trace(err)
-	}
-
-	// FrontSpec.Addresses may include a port; default to 443 if none.
-
-	if _, _, err := net.SplitHostPort(brokerDialParams.FrontingDialAddress); err == nil {
-		brokerDialParams.DialAddress = brokerDialParams.FrontingDialAddress
-	} else {
-		brokerDialParams.DialAddress = net.JoinHostPort(brokerDialParams.FrontingDialAddress, "443")
-	}
-
-	// SNI configuration
-	//
-	// For a FrontingSpec, an SNI value of "" indicates to disable/omit SNI, so
-	// never transform in that case.
-
-	if brokerDialParams.SNIServerName != "" {
-		if p.WeightedCoinFlip(parameters.TransformHostNameProbability) {
-			brokerDialParams.SNIServerName = selectHostName(equivilentTunnelProtocol, p)
-			brokerDialParams.TransformedHostName = true
-		}
-	}
-
-	// TLS configuration
-	//
-	// The requireTLS13 flag is set to true in order to use only modern TLS
-	// fingerprints which should support HTTP/2 in the ALPN.
-	//
-	// TODO: TLS padding, NoDefaultTLSSessionID
-
-	brokerDialParams.TLSProfile,
-		brokerDialParams.TLSVersion,
-		brokerDialParams.RandomizedTLSProfileSeed,
-		err = SelectTLSProfile(false, true, true, brokerDialParams.FrontingProviderID, p)
-
-	brokerDialParams.NoDefaultTLSSessionID = p.WeightedCoinFlip(
-		parameters.NoDefaultTLSSessionIDProbability)
-
-	if brokerDialParams.SNIServerName != "" && net.ParseIP(brokerDialParams.SNIServerName) == nil {
-		tlsFragmentorLimitProtocols := p.TunnelProtocols(parameters.TLSFragmentClientHelloLimitProtocols)
-		if len(tlsFragmentorLimitProtocols) == 0 || common.Contains(tlsFragmentorLimitProtocols, equivilentTunnelProtocol) {
-			brokerDialParams.TLSFragmentClientHello = p.WeightedCoinFlip(parameters.TLSFragmentClientHelloProbability)
-		}
-	}
-
-	// User Agent configuration
-
-	dialCustomHeaders := makeDialCustomHeaders(config, p)
-	brokerDialParams.SelectedUserAgent, brokerDialParams.UserAgent = selectUserAgentIfUnset(p, dialCustomHeaders)
-
-	// BPF configuration
-
-	if ClientBPFEnabled() &&
-		protocol.TunnelProtocolMayUseClientBPF(equivilentTunnelProtocol) {
-
-		if p.WeightedCoinFlip(parameters.BPFClientTCPProbability) {
-			brokerDialParams.BPFProgramName = ""
-			brokerDialParams.BPFProgramInstructions = nil
-			ok, name, rawInstructions := p.BPFProgram(parameters.BPFClientTCPProgram)
-			if ok {
-				brokerDialParams.BPFProgramName = name
-				brokerDialParams.BPFProgramInstructions = rawInstructions
-			}
-		}
-	}
+	// The broker round trips use MeekModeWrappedPlaintextRoundTrip without
+	// meek cookies, so meek obfuscation is not configured. The in-proxy
+	// broker session payloads have their own obfuscation layer.
 
-	// Fragmentor configuration
+	payloadSecure := true
+	skipVerify := false
 
-	brokerDialParams.FragmentorSeed, err = prng.NewSeed()
+	var err error
+	brokerDialParams.FrontedHTTPDialParameters, err = makeFrontedMeekDialParameters(
+		config,
+		p,
+		nil,
+		brokerSpec.BrokerFrontingSpecs,
+		nil,
+		true,
+		skipVerify,
+		config.DisableSystemRootCAs,
+		payloadSecure)
 	if err != nil {
 		return nil, errors.Trace(err)
 	}
 
-	// Resolver configuration
-	//
-	// The custom resolcer is wired up only when there is a domain to be
-	// resolved; GetMetrics will log resolver metrics when the resolver is set.
-
-	if net.ParseIP(brokerDialParams.FrontingDialAddress) == nil {
-
-		resolver := config.GetResolver()
-		if resolver == nil {
-			return nil, errors.TraceNew("missing resolver")
-		}
-
-		brokerDialParams.ResolveParameters, err = resolver.MakeResolveParameters(
-			p, brokerDialParams.FrontingProviderID, brokerDialParams.FrontingDialAddress)
-		if err != nil {
-			return nil, errors.Trace(err)
-		}
-	}
-
 	// Initialize Dial/MeekConfigs to be passed to the corresponding dialers.
 
-	err = brokerDialParams.prepareDialConfigs(config, p, networkID, false, dialCustomHeaders)
+	err = brokerDialParams.prepareDialConfigs(
+		config,
+		p,
+		false)
 	if err != nil {
 		return nil, errors.Trace(err)
 	}
@@ -1259,115 +1123,34 @@ func MakeInproxyBrokerDialParameters(
 func (brokerDialParams *InproxyBrokerDialParameters) prepareDialConfigs(
 	config *Config,
 	p parameters.ParametersAccessor,
-	networkID string,
-	isReplay bool,
-	dialCustomHeaders http.Header) error {
+	isReplay bool) error {
 
 	brokerDialParams.isReplay = isReplay
 
-	equivilentTunnelProtocol, err := protocol.EquivilentTunnelProtocol(brokerDialParams.BrokerTransport)
-	if err != nil {
-		return errors.Trace(err)
-	}
-
-	// Custom headers and User Agent
-
-	if dialCustomHeaders == nil {
-		dialCustomHeaders = makeDialCustomHeaders(config, p)
-	}
-	if brokerDialParams.SelectedUserAgent {
-
-		// Limitation: if config.CustomHeaders adds a User-Agent between
-		// replays, it may be ignored due to replaying a selected User-Agent.
-		dialCustomHeaders.Set("User-Agent", brokerDialParams.UserAgent)
-	}
-
-	// Fragmentor
-
-	fragmentorConfig := fragmentor.NewUpstreamConfig(
-		p, equivilentTunnelProtocol, brokerDialParams.FragmentorSeed)
-
-	// Resolver
-	//
-	// DialConfig.ResolveIP is required and called even when the destination
-	// is an IP address.
-
-	resolver := config.GetResolver()
-	if resolver == nil {
-		return errors.TraceNew("missing resolver")
-	}
-
-	resolveIP := func(ctx context.Context, hostname string) ([]net.IP, error) {
-		IPs, err := resolver.ResolveIP(
-			ctx, networkID, brokerDialParams.ResolveParameters, hostname)
-		return IPs, errors.Trace(err)
-	}
-
-	// DialConfig
+	if isReplay {
+		// FrontedHTTPDialParameters
+		//
+		// The broker round trips use MeekModeWrappedPlaintextRoundTrip without
+		// meek cookies, so meek obfuscation is not configured. The in-proxy
+		// broker session payloads have their own obfuscation layer.
 
-	brokerDialParams.ResolvedIPAddress.Store("")
+		payloadSecure := true
+		skipVerify := false
 
-	brokerDialParams.dialConfig = &DialConfig{
-		DiagnosticID:                  brokerDialParams.brokerSpec.BrokerPublicKey,
-		CustomHeaders:                 dialCustomHeaders,
-		BPFProgramInstructions:        brokerDialParams.BPFProgramInstructions,
-		DeviceBinder:                  config.deviceBinder,
-		IPv6Synthesizer:               config.IPv6Synthesizer,
-		ResolveIP:                     resolveIP,
-		TrustedCACertificatesFilename: config.TrustedCACertificatesFilename,
-		FragmentorConfig:              fragmentorConfig,
-		ResolvedIPCallback: func(IPAddress string) {
-			brokerDialParams.ResolvedIPAddress.Store(IPAddress)
-		},
-	}
-
-	// MeekDialConfig
-	//
-	// The broker round trips use MeekModeWrappedPlaintextRoundTrip without
-	// meek cookies, so meek obfuscation is not configured. The in-proxy
-	// broker session payloads have their own obfuscation layer.
-
-	addPsiphonFrontingHeader := false
-	if brokerDialParams.FrontingProviderID != "" {
-		addPsiphonFrontingHeader = common.Contains(
-			p.LabeledTunnelProtocols(
-				parameters.AddFrontingProviderPsiphonFrontingHeader,
-				brokerDialParams.FrontingProviderID),
-			equivilentTunnelProtocol)
-	}
-
-	brokerDialParams.meekConfig = &MeekConfig{
-		Mode:                     MeekModeWrappedPlaintextRoundTrip,
-		DiagnosticID:             brokerDialParams.FrontingProviderID,
-		Parameters:               config.GetParameters(),
-		DialAddress:              brokerDialParams.DialAddress,
-		TLSProfile:               brokerDialParams.TLSProfile,
-		NoDefaultTLSSessionID:    brokerDialParams.NoDefaultTLSSessionID,
-		RandomizedTLSProfileSeed: brokerDialParams.RandomizedTLSProfileSeed,
-		SNIServerName:            brokerDialParams.SNIServerName,
-		AddPsiphonFrontingHeader: addPsiphonFrontingHeader,
-		VerifyServerName:         brokerDialParams.VerifyServerName,
-		VerifyPins:               brokerDialParams.VerifyPins,
-		HostHeader:               brokerDialParams.HostHeader,
-		TransformedHostName:      brokerDialParams.TransformedHostName,
-		NetworkLatencyMultiplier: brokerDialParams.NetworkLatencyMultiplier,
-		AdditionalHeaders:        config.MeekAdditionalHeaders,
-		TLSClientSessionCache:    common.WrapUtlsClientSessionCache(utls.NewLRUClientSessionCache(0), brokerDialParams.DialAddress),
-	}
-
-	switch brokerDialParams.BrokerTransport {
-	case protocol.FRONTING_TRANSPORT_HTTPS:
-		brokerDialParams.meekConfig.UseHTTPS = true
-	case protocol.FRONTING_TRANSPORT_QUIC:
-		brokerDialParams.meekConfig.UseQUIC = true
+		err := brokerDialParams.FrontedHTTPDialParameters.prepareDialConfigs(
+			config, p, nil, nil, true, skipVerify,
+			config.DisableSystemRootCAs, payloadSecure)
+		if err != nil {
+			return errors.Trace(err)
+		}
 	}
 
 	return nil
 }
 
-// GetBrokerMetrics returns  dial parameter log fields to be reported to a
-// broker.
-func (brokerDialParams *InproxyBrokerDialParameters) GetBrokerMetrics() common.LogFields {
+// GetMetricsForBroker returns broker client dial parameter log fields to be
+// reported to a broker.
+func (brokerDialParams *InproxyBrokerDialParameters) GetMetricsForBrokerRequests() common.LogFields {
 
 	logFields := common.LogFields{}
 
@@ -1375,7 +1158,7 @@ func (brokerDialParams *InproxyBrokerDialParameters) GetBrokerMetrics() common.L
 	// the broker -- as successful parameters might not otherwise by logged
 	// via server_tunnel if the subsequent WebRTC dials fail.
 
-	logFields["fronting_provider_id"] = brokerDialParams.FrontingProviderID
+	logFields["fronting_provider_id"] = brokerDialParams.FrontedHTTPDialParameters.FrontingProviderID
 
 	return logFields
 }
@@ -1386,7 +1169,11 @@ func (brokerDialParams *InproxyBrokerDialParameters) GetMetrics() common.LogFiel
 
 	logFields := common.LogFields{}
 
-	logFields["inproxy_broker_transport"] = brokerDialParams.BrokerTransport
+	// Add underlying log fields, which must be renamed to be scoped to the
+	// broker.
+	logFields.Add(brokerDialParams.FrontedHTTPDialParameters.GetMetrics("inproxy_broker_"))
+
+	logFields["inproxy_broker_transport"] = brokerDialParams.FrontedHTTPDialParameters.FrontingTransport
 
 	isReplay := "0"
 	if brokerDialParams.isReplay {
@@ -1394,80 +1181,6 @@ func (brokerDialParams *InproxyBrokerDialParameters) GetMetrics() common.LogFiel
 	}
 	logFields["inproxy_broker_is_replay"] = isReplay
 
-	// Note: as At the broker client transport is currently limited to domain
-	// fronted HTTPS, the following related parameters are included
-	// unconditionally.
-
-	logFields["inproxy_broker_fronting_provider_id"] = brokerDialParams.FrontingProviderID
-
-	logFields["inproxy_broker_dial_address"] = brokerDialParams.FrontingDialAddress
-
-	resolvedIPAddress := brokerDialParams.ResolvedIPAddress.Load().(string)
-	if resolvedIPAddress != "" {
-		logFields["inproxy_broker_resolved_ip_address"] = resolvedIPAddress
-	}
-
-	if brokerDialParams.SNIServerName != "" {
-		logFields["inproxy_broker_sni_server_name"] = brokerDialParams.SNIServerName
-	}
-
-	logFields["inproxy_broker_host_header"] = brokerDialParams.HostHeader
-
-	transformedHostName := "0"
-	if brokerDialParams.TransformedHostName {
-		transformedHostName = "1"
-	}
-	logFields["inproxy_broker_transformed_host_name"] = transformedHostName
-
-	if brokerDialParams.UserAgent != "" {
-		logFields["inproxy_broker_user_agent"] = brokerDialParams.UserAgent
-	}
-
-	if brokerDialParams.BrokerTransport == protocol.FRONTING_TRANSPORT_HTTPS {
-
-		if brokerDialParams.TLSProfile != "" {
-			logFields["inproxy_broker_tls_profile"] = brokerDialParams.TLSProfile
-		}
-
-		logFields["inproxy_broker_tls_version"] = brokerDialParams.TLSVersion
-
-		tlsFragmented := "0"
-		if brokerDialParams.TLSFragmentClientHello {
-			tlsFragmented = "1"
-		}
-		logFields["inproxy_broker_tls_fragmented"] = tlsFragmented
-	}
-
-	if brokerDialParams.BPFProgramName != "" {
-		logFields["inproxy_broker_client_bpf"] = brokerDialParams.BPFProgramName
-	}
-
-	if brokerDialParams.ResolveParameters != nil {
-
-		// See comment for dialParams.ResolveParameters handling in
-		// getBaseAPIParameters.
-
-		if brokerDialParams.ResolveParameters.PreresolvedIPAddress != "" {
-			dialDomain, _, _ := net.SplitHostPort(brokerDialParams.DialAddress)
-			if brokerDialParams.ResolveParameters.PreresolvedDomain == dialDomain {
-				logFields["inproxy_broker_dns_preresolved"] = brokerDialParams.ResolveParameters.PreresolvedIPAddress
-			}
-		}
-
-		if brokerDialParams.ResolveParameters.PreferAlternateDNSServer {
-			logFields["inproxy_broker_dns_preferred"] = brokerDialParams.ResolveParameters.AlternateDNSServer
-		}
-
-		if brokerDialParams.ResolveParameters.ProtocolTransformName != "" {
-			logFields["inproxy_broker_dns_transform"] = brokerDialParams.ResolveParameters.ProtocolTransformName
-		}
-
-		logFields["inproxy_broker_dns_attempt"] = strconv.Itoa(
-			brokerDialParams.ResolveParameters.GetFirstAttemptWithAnswer())
-	}
-
-	// TODO: get fragmentor metrics, if any, from MeekConn.
-
 	return logFields
 }
 
@@ -1625,8 +1338,8 @@ func (rt *InproxyBrokerRoundTripper) RoundTrip(
 
 		conn, err := DialMeek(
 			requestCtx,
-			rt.brokerDialParams.meekConfig,
-			rt.brokerDialParams.dialConfig)
+			rt.brokerDialParams.FrontedHTTPDialParameters.meekConfig,
+			rt.brokerDialParams.FrontedHTTPDialParameters.dialConfig)
 
 		if err != nil && ctx.Err() != context.Canceled {
 
@@ -1673,7 +1386,7 @@ func (rt *InproxyBrokerRoundTripper) RoundTrip(
 	// MeekConn in favor of the MeekDialConfig, while the path will be used.
 	url := fmt.Sprintf(
 		"https://%s/%s",
-		rt.brokerDialParams.DialAddress,
+		rt.brokerDialParams.FrontedHTTPDialParameters.DialAddress,
 		inproxy.BrokerEndPointName)
 
 	request, err := http.NewRequestWithContext(
@@ -2602,13 +2315,6 @@ func newInproxyUDPConn(ctx context.Context, config *Config) (net.PacketConn, err
 	return conn, nil
 }
 
-func inproxyUDPAddrFromAddrPort(addrPort netip.AddrPort) *net.UDPAddr {
-	return &net.UDPAddr{
-		IP:   addrPort.Addr().AsSlice(),
-		Port: int(addrPort.Port()),
-	}
-}
-
 func (conn *inproxyUDPConn) ReadFrom(p []byte) (int, net.Addr, error) {
 
 	// net.UDPConn.ReadFrom currently allocates a &UDPAddr{} per call, and so

+ 14 - 16
psiphon/inproxy_test.go

@@ -31,6 +31,7 @@ import (
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/resolver"
+	"github.com/stretchr/testify/assert"
 )
 
 func TestInproxyComponents(t *testing.T) {
@@ -39,7 +40,7 @@ func TestInproxyComponents(t *testing.T) {
 	// replay; actual in-proxy broker round trips are exercised in the
 	// psiphon/server end-to-end tests.
 
-	err := runInproxyBrokerDialParametersTest()
+	err := runInproxyBrokerDialParametersTest(t)
 	if err != nil {
 		t.Fatalf(errors.Trace(err).Error())
 	}
@@ -57,7 +58,7 @@ func TestInproxyComponents(t *testing.T) {
 	// TODO: test inproxyUDPConn multiplexed IPv6Synthesizer
 }
 
-func runInproxyBrokerDialParametersTest() error {
+func runInproxyBrokerDialParametersTest(t *testing.T) error {
 
 	testDataDirName, err := ioutil.TempDir("", "psiphon-inproxy-broker-test")
 	if err != nil {
@@ -143,7 +144,7 @@ func runInproxyBrokerDialParametersTest() error {
 	}
 
 	if !regexp.MustCompile(addressRegex).Copy().Match(
-		[]byte(brokerDialParams.FrontingDialAddress)) {
+		[]byte(brokerDialParams.FrontedHTTPDialParameters.meekConfig.DialAddress)) {
 		return errors.TraceNew("unexpected FrontingDialAddress")
 	}
 
@@ -157,8 +158,10 @@ func runInproxyBrokerDialParametersTest() error {
 
 	// Test: replay on success
 
-	previousFrontingDialAddress := brokerDialParams.FrontingDialAddress
-	previousTLSProfile := brokerDialParams.TLSProfile
+	prevBrokerDialParams := brokerDialParams
+
+	previousFrontingDialAddress := brokerDialParams.FrontedHTTPDialParameters.meekConfig.DialAddress
+	previousTLSProfile := brokerDialParams.FrontedHTTPDialParameters.meekConfig.TLSProfile
 
 	roundTripper, err := brokerClient.GetBrokerDialCoordinator().BrokerClientRoundTripper()
 	if err != nil {
@@ -178,13 +181,8 @@ func runInproxyBrokerDialParametersTest() error {
 		return errors.TraceNew("unexpected non-replay")
 	}
 
-	if brokerDialParams.FrontingDialAddress != previousFrontingDialAddress {
-		return errors.TraceNew("unexpected replayed FrontingDialAddress")
-	}
-
-	if brokerDialParams.TLSProfile != previousTLSProfile {
-		return errors.TraceNew("unexpected replayed TLSProfile")
-	}
+	// All exported fields should be replayed
+	assert.EqualExportedValues(t, brokerDialParams, prevBrokerDialParams)
 
 	_ = brokerDialParams.GetMetrics()
 
@@ -210,7 +208,7 @@ func runInproxyBrokerDialParametersTest() error {
 		return errors.TraceNew("unexpected replay")
 	}
 
-	if brokerDialParams.FrontingDialAddress == previousFrontingDialAddress {
+	if brokerDialParams.FrontedHTTPDialParameters.meekConfig.DialAddress == previousFrontingDialAddress {
 		return errors.TraceNew("unexpected non-replayed FrontingDialAddress")
 	}
 
@@ -230,11 +228,11 @@ func runInproxyBrokerDialParametersTest() error {
 		return errors.TraceNew("unexpected non-replay")
 	}
 
-	if brokerDialParams.FrontingDialAddress != previousFrontingDialAddress {
+	if brokerDialParams.FrontedHTTPDialParameters.meekConfig.DialAddress != previousFrontingDialAddress {
 		return errors.TraceNew("unexpected replayed FrontingDialAddress")
 	}
 
-	if brokerDialParams.TLSProfile != previousTLSProfile {
+	if brokerDialParams.FrontedHTTPDialParameters.meekConfig.TLSProfile != previousTLSProfile {
 		return errors.TraceNew("unexpected replayed TLSProfile")
 	}
 
@@ -260,7 +258,7 @@ func runInproxyBrokerDialParametersTest() error {
 		return errors.TraceNew("unexpected replay")
 	}
 
-	if brokerDialParams.FrontingDialAddress == previousFrontingDialAddress {
+	if brokerDialParams.FrontedHTTPDialParameters.meekConfig.DialAddress == previousFrontingDialAddress {
 		return errors.TraceNew("unexpected non-replayed FrontingDialAddress")
 	}
 

+ 36 - 260
psiphon/net.go

@@ -39,8 +39,6 @@ import (
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/fragmentor"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters"
-	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
-	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/resolver"
 	utls "github.com/Psiphon-Labs/utls"
 	"golang.org/x/net/bpf"
@@ -410,258 +408,30 @@ func UntunneledResolveIP(
 // payloadSecure must only be set if all HTTP plaintext payloads sent through
 // the returned net/http.Client will be wrapped in their own transport security
 // layer, which permits skipping of server certificate verification.
-//
-// Warning: it is not safe to call makeFrontedHTTPClient concurrently with the
-// same dialConfig when tunneled is true because dialConfig will be used
-// directly, instead of copied, which can lead to a crash when fields not safe
-// for concurrent use are present.
 func makeFrontedHTTPClient(
-	ctx context.Context,
 	config *Config,
-	tunneled bool,
-	dialConfig *DialConfig,
+	tunnel *Tunnel,
 	frontingSpecs parameters.FrontingSpecs,
 	selectedFrontingProviderID func(string),
+	useDeviceBinder,
 	skipVerify,
 	disableSystemRootCAs,
 	payloadSecure bool) (*http.Client, func() common.APIParameters, error) {
 
-	if !payloadSecure && (skipVerify || disableSystemRootCAs) {
-		return nil, nil, errors.TraceNew("cannot skip certificate verification if payload insecure")
-	}
-
-	frontingProviderID,
-		frontingTransport,
-		meekFrontingDialAddress,
-		meekSNIServerName,
-		meekVerifyServerName,
-		meekVerifyPins,
-		meekFrontingHost, err := parameters.FrontingSpecs(frontingSpecs).SelectParameters()
-	if err != nil {
-		return nil, nil, errors.Trace(err)
-	}
-
-	if frontingTransport != protocol.FRONTING_TRANSPORT_HTTPS {
-		return nil, nil, errors.TraceNew("unsupported fronting transport")
-	}
-
-	if selectedFrontingProviderID != nil {
-		selectedFrontingProviderID(frontingProviderID)
-	}
-
-	meekDialAddress := net.JoinHostPort(meekFrontingDialAddress, "443")
-	meekHostHeader := meekFrontingHost
-
-	p := config.GetParameters().Get()
-	effectiveTunnelProtocol := protocol.TUNNEL_PROTOCOL_FRONTED_MEEK
-
-	requireTLS12SessionTickets := protocol.TunnelProtocolRequiresTLS12SessionTickets(
-		effectiveTunnelProtocol)
-	requireTLS13Support := protocol.TunnelProtocolRequiresTLS13Support(effectiveTunnelProtocol)
-	isFronted := true
-
-	tlsProfile, tlsVersion, randomizedTLSProfileSeed, err := SelectTLSProfile(
-		requireTLS12SessionTickets, requireTLS13Support, isFronted, frontingProviderID, p)
+	frontedHTTPClient, err := newFrontedHTTPClientInstance(
+		config, tunnel, frontingSpecs, selectedFrontingProviderID,
+		useDeviceBinder, skipVerify, disableSystemRootCAs, payloadSecure)
 	if err != nil {
 		return nil, nil, errors.Trace(err)
 	}
 
-	if tlsProfile == "" && (requireTLS12SessionTickets || requireTLS13Support) {
-		return nil, nil, errors.TraceNew("required TLS profile not found")
-	}
-
-	noDefaultTLSSessionID := p.WeightedCoinFlip(
-		parameters.NoDefaultTLSSessionIDProbability)
-
-	// For a FrontingSpec, an SNI value of "" indicates to disable/omit SNI, so
-	// never transform in that case.
-	var meekTransformedHostName bool
-	if meekSNIServerName != "" {
-		if p.WeightedCoinFlip(parameters.TransformHostNameProbability) {
-			meekSNIServerName = selectHostName(effectiveTunnelProtocol, p)
-			meekTransformedHostName = true
-		}
-	}
-
-	addPsiphonFrontingHeader := false
-	if frontingProviderID != "" {
-		addPsiphonFrontingHeader = common.Contains(
-			p.LabeledTunnelProtocols(
-				parameters.AddFrontingProviderPsiphonFrontingHeader, frontingProviderID),
-			effectiveTunnelProtocol)
-	}
-
-	networkLatencyMultiplierMin := p.Float(parameters.NetworkLatencyMultiplierMin)
-	networkLatencyMultiplierMax := p.Float(parameters.NetworkLatencyMultiplierMax)
-
-	networkLatencyMultiplier := prng.ExpFloat64Range(
-		networkLatencyMultiplierMin,
-		networkLatencyMultiplierMax,
-		p.Float(parameters.NetworkLatencyMultiplierLambda))
-
-	tlsFragmentClientHello := false
-	if meekSNIServerName != "" {
-		tlsFragmentorLimitProtocols := p.TunnelProtocols(parameters.TLSFragmentClientHelloLimitProtocols)
-		if len(tlsFragmentorLimitProtocols) == 0 || common.Contains(tlsFragmentorLimitProtocols, effectiveTunnelProtocol) {
-			if net.ParseIP(meekSNIServerName) == nil {
-				tlsFragmentClientHello = p.WeightedCoinFlip(parameters.TLSFragmentClientHelloProbability)
-			}
-		}
-	}
-
-	var meekMode MeekMode = MeekModePlaintextRoundTrip
-	if payloadSecure {
-		meekMode = MeekModeWrappedPlaintextRoundTrip
-	}
-
-	meekConfig := &MeekConfig{
-		DiagnosticID:             frontingProviderID,
-		Parameters:               config.GetParameters(),
-		Mode:                     meekMode,
-		DialAddress:              meekDialAddress,
-		UseHTTPS:                 true,
-		TLSProfile:               tlsProfile,
-		TLSFragmentClientHello:   tlsFragmentClientHello,
-		NoDefaultTLSSessionID:    noDefaultTLSSessionID,
-		RandomizedTLSProfileSeed: randomizedTLSProfileSeed,
-		SNIServerName:            meekSNIServerName,
-		AddPsiphonFrontingHeader: addPsiphonFrontingHeader,
-		HostHeader:               meekHostHeader,
-		TransformedHostName:      meekTransformedHostName,
-		ClientTunnelProtocol:     effectiveTunnelProtocol,
-		NetworkLatencyMultiplier: networkLatencyMultiplier,
-		// TODO: Change hard-coded session key be something like FrontingProviderID + BrokerID.
-		// This is necessary once longer-term TLS caches are added.
-		// meekDialAddress, based on meekFrontingDialAddress has couple of issues. For some providers there's
-		// only a couple or even just one possible value, in other cases there are millions of possible values
-		// and cached values wont' be used as often as they ought to be.
-		TLSClientSessionCache: common.WrapUtlsClientSessionCache(utls.NewLRUClientSessionCache(0), meekDialAddress),
-	}
-
-	if !skipVerify {
-		meekConfig.DisableSystemRootCAs = disableSystemRootCAs
-		if !meekConfig.DisableSystemRootCAs {
-			meekConfig.VerifyServerName = meekVerifyServerName
-			meekConfig.VerifyPins = meekVerifyPins
-		}
-	}
-
-	var resolvedIPAddress atomic.Value
-	resolvedIPAddress.Store("")
-
-	var meekDialConfig *DialConfig
-	if tunneled {
-		meekDialConfig = dialConfig
-	} else {
-		// The default untunneled dial config does not support pre-resolved IPs so
-		// redefine the dial config to override ResolveIP with an implementation
-		// that enables their use by passing the fronting provider ID into
-		// UntunneledResolveIP.
-		meekDialConfig = &DialConfig{
-			UpstreamProxyURL: dialConfig.UpstreamProxyURL,
-			CustomHeaders:    makeDialCustomHeaders(config, p),
-			DeviceBinder:     dialConfig.DeviceBinder,
-			IPv6Synthesizer:  dialConfig.IPv6Synthesizer,
-			ResolveIP: func(ctx context.Context, hostname string) ([]net.IP, error) {
-				IPs, err := UntunneledResolveIP(
-					ctx, config, config.GetResolver(), hostname, frontingProviderID)
-				if err != nil {
-					return nil, errors.Trace(err)
-				}
-				return IPs, nil
-			},
-			ResolvedIPCallback: func(IPAddress string) {
-				resolvedIPAddress.Store(IPAddress)
-			},
-		}
-	}
-
-	selectedUserAgent, userAgent := selectUserAgentIfUnset(p, meekDialConfig.CustomHeaders)
-	if selectedUserAgent {
-		if meekDialConfig.CustomHeaders == nil {
-			meekDialConfig.CustomHeaders = make(http.Header)
-		}
-		meekDialConfig.CustomHeaders.Set("User-Agent", userAgent)
-	}
-
-	// Use MeekConn to domain front requests.
-	//
-	// DialMeek will create a TLS connection immediately. We will delay
-	// initializing the MeekConn-based RoundTripper until we know it's needed.
-	// This is implemented by passing in a RoundTripper that establishes a
-	// MeekConn when RoundTrip is called.
-	//
-	// Resources are cleaned up when the response body is closed.
-	roundTrip := func(request *http.Request) (*http.Response, error) {
-
-		conn, err := DialMeek(
-			ctx, meekConfig, meekDialConfig)
-		if err != nil {
-			return nil, errors.Trace(err)
-		}
-
-		response, err := conn.RoundTrip(request)
-		if err != nil {
-			return nil, errors.Trace(err)
-		}
-
-		// Do not read the response body into memory all at once because it may
-		// be large. Instead allow the caller to stream the response.
-		response.Body = newMeekHTTPResponseReadCloser(conn, response.Body)
-
-		return response, nil
-	}
-
-	params := func() common.APIParameters {
-		params := make(common.APIParameters)
-
-		params["fronting_provider_id"] = frontingProviderID
-
-		if meekConfig.DialAddress != "" {
-			params["meek_dial_address"] = meekConfig.DialAddress
-		}
-
-		meekResolvedIPAddress := resolvedIPAddress.Load()
-		if meekResolvedIPAddress != "" {
-			params["meek_resolved_ip_address"] = meekResolvedIPAddress
-		}
-
-		if meekConfig.SNIServerName != "" {
-			params["meek_sni_server_name"] = meekConfig.SNIServerName
-		}
-
-		if meekConfig.HostHeader != "" {
-			params["meek_host_header"] = meekConfig.HostHeader
-		}
-
-		transformedHostName := "0"
-		if meekTransformedHostName {
-			transformedHostName = "1"
-		}
-		params["meek_transformed_host_name"] = transformedHostName
-
-		if meekConfig.TLSProfile != "" {
-			params["tls_profile"] = meekConfig.TLSProfile
-		}
-
-		if selectedUserAgent {
-			params["user_agent"] = userAgent
-		}
-
-		if tlsVersion != "" {
-			params["tls_version"] = getTLSVersionForMetrics(tlsVersion, meekConfig.NoDefaultTLSSessionID)
-		}
-
-		if meekConfig.TLSFragmentClientHello {
-			params["tls_fragmented"] = "1"
-		}
-
-		return params
+	getParams := func() common.APIParameters {
+		return common.APIParameters(frontedHTTPClient.frontedHTTPDialParameters.GetMetrics())
 	}
 
 	return &http.Client{
-		Transport: common.NewHTTPRoundTripper(roundTrip),
-	}, params, nil
+		Transport: common.NewHTTPRoundTripper(frontedHTTPClient.RoundTrip),
+	}, getParams, nil
 }
 
 // meekHTTPResponseReadCloser wraps an http.Response.Body received over a
@@ -708,19 +478,24 @@ func MakeUntunneledHTTPClient(
 	disableSystemRootCAs bool,
 	payloadSecure bool,
 	frontingSpecs parameters.FrontingSpecs,
+	frontingUseDeviceBinder bool,
 	selectedFrontingProviderID func(string)) (*http.Client, func() common.APIParameters, error) {
 
+	if untunneledDialConfig != nil && len(frontingSpecs) != 0 ||
+		untunneledDialConfig == nil && len(frontingSpecs) == 0 {
+		return nil, nil, errors.TraceNew("expected either dial configuration or fronting specs")
+	}
+
 	if len(frontingSpecs) > 0 {
 
 		// Ignore skipVerify because it only applies when there are no
 		// fronting specs.
 		httpClient, getParams, err := makeFrontedHTTPClient(
-			ctx,
 			config,
-			false,
-			untunneledDialConfig,
+			nil,
 			frontingSpecs,
 			selectedFrontingProviderID,
+			frontingUseDeviceBinder,
 			false,
 			disableSystemRootCAs,
 			payloadSecure)
@@ -778,32 +553,17 @@ func MakeTunneledHTTPClient(
 	// Note: there is no dial context since SSH port forward dials cannot
 	// be interrupted directly. Closing the tunnel will interrupt the dials.
 
-	tunneledDialer := func(_, addr string) (net.Conn, error) {
-		// Set alwaysTunneled to ensure the http.Client traffic is always tunneled,
-		// even when split tunnel mode is enabled.
-		conn, _, err := tunnel.DialTCPChannel(addr, true, nil)
-		return conn, errors.Trace(err)
-	}
-
 	if len(frontingSpecs) > 0 {
 
-		dialConfig := &DialConfig{
-			TrustedCACertificatesFilename: config.TrustedCACertificatesFilename,
-			CustomDialer: func(_ context.Context, _, addr string) (net.Conn, error) {
-				return tunneledDialer("", addr)
-			},
-		}
-
 		// Ignore skipVerify because it only applies when there are no
 		// fronting specs.
 		httpClient, getParams, err := makeFrontedHTTPClient(
-			ctx,
 			config,
-			true,
-			dialConfig,
+			tunnel,
 			frontingSpecs,
 			selectedFrontingProviderID,
 			false,
+			false,
 			disableSystemRootCAs,
 			payloadSecure)
 		if err != nil {
@@ -812,6 +572,13 @@ func MakeTunneledHTTPClient(
 		return httpClient, getParams, nil
 	}
 
+	tunneledDialer := func(_, addr string) (net.Conn, error) {
+		// Set alwaysTunneled to ensure the http.Client traffic is always tunneled,
+		// even when split tunnel mode is enabled.
+		conn, _, err := tunnel.DialTCPChannel(addr, true, nil)
+		return conn, errors.Trace(err)
+	}
+
 	transport := &http.Transport{
 		Dial: tunneledDialer,
 	}
@@ -851,6 +618,7 @@ func MakeDownloadHTTPClient(
 	disableSystemRootCAs,
 	payloadSecure bool,
 	frontingSpecs parameters.FrontingSpecs,
+	frontingUseDeviceBinder bool,
 	selectedFrontingProviderID func(string)) (*http.Client, bool, func() common.APIParameters, error) {
 
 	var httpClient *http.Client
@@ -875,14 +643,22 @@ func MakeDownloadHTTPClient(
 		}
 
 	} else {
+
+		var dialConfig *DialConfig
+		if len(frontingSpecs) == 0 {
+			// Must only set DialConfig if there are no fronting specs.
+			dialConfig = untunneledDialConfig
+		}
+
 		httpClient, getParams, err = MakeUntunneledHTTPClient(
 			ctx,
 			config,
-			untunneledDialConfig,
+			dialConfig,
 			skipVerify,
 			disableSystemRootCAs,
 			payloadSecure,
 			frontingSpecs,
+			frontingUseDeviceBinder,
 			selectedFrontingProviderID)
 		if err != nil {
 			return nil, false, nil, errors.Trace(err)

+ 2 - 3
psiphon/notice.go

@@ -699,12 +699,11 @@ func NoticeRequestedTactics(dialParams *DialParameters) {
 }
 
 // NoticeActiveTunnel is a successful connection that is used as an active tunnel for port forwarding
-func NoticeActiveTunnel(diagnosticID, protocol string, isTCS bool) {
+func NoticeActiveTunnel(diagnosticID, protocol string) {
 	singletonNoticeLogger.outputNotice(
 		"ActiveTunnel", noticeIsDiagnostic,
 		"diagnosticID", diagnosticID,
-		"protocol", protocol,
-		"isTCS", isTCS)
+		"protocol", protocol)
 }
 
 // NoticeConnectedServerRegion reports the region of the connected server

+ 7 - 1
psiphon/remoteServerList.go

@@ -167,7 +167,11 @@ func FetchObfuscatedServerLists(
 	// the registry, so clear the ETag to ensure that always happens.
 	_, err := os.Stat(cachedFilename)
 	if os.IsNotExist(err) {
-		SetUrlETag(canonicalURL, "")
+		err := SetUrlETag(canonicalURL, "")
+		if err != nil {
+			NoticeWarning("SetUrlETag failed: %v", errors.Trace(err))
+			// Continue
+		}
 	}
 
 	// failed is set if any operation fails and should trigger a retry. When the OSL registry
@@ -462,6 +466,7 @@ func downloadRemoteServerListFile(
 	// or untunneled configuration.
 
 	payloadSecure := true
+	frontingUseDeviceBinder := true
 	httpClient, tunneled, getParams, err := MakeDownloadHTTPClient(
 		ctx,
 		config,
@@ -471,6 +476,7 @@ func downloadRemoteServerListFile(
 		disableSystemRootCAs,
 		payloadSecure,
 		frontingSpecs,
+		frontingUseDeviceBinder,
 		func(frontingProviderID string) {
 			NoticeInfo(
 				"downloadRemoteServerListFile: selected fronting provider %s for %s",

+ 1 - 1
psiphon/server/api.go

@@ -1788,7 +1788,7 @@ func isGeoHashString(_ *Config, value string) bool {
 		return false
 	}
 	for _, c := range value {
-		if strings.Index(geohashAlphabet, string(c)) == -1 {
+		if !strings.Contains(geohashAlphabet, string(c)) {
 			return false
 		}
 	}

+ 23 - 8
psiphon/server/config.go

@@ -496,11 +496,12 @@ type Config struct {
 	peakUpstreamFailureRateMinimumSampleSize       int
 	periodicGarbageCollection                      time.Duration
 	stopEstablishTunnelsEstablishedClientThreshold int
-	dumpProfilesOnStopEstablishTunnelsDone         int32
+	dumpProfilesOnStopEstablishTunnelsDoneOnce     int32
 	providerID                                     string
 	frontingProviderID                             string
 	region                                         string
 	runningProtocols                               []string
+	runningOnlyInproxyBroker                       bool
 }
 
 // GetLogFileReopenConfig gets the reopen retries, and create/mode inputs for
@@ -547,11 +548,18 @@ func (config *Config) DumpProfilesOnStopEstablishTunnels(establishedClientsCount
 	if config.stopEstablishTunnelsEstablishedClientThreshold < 0 {
 		return false
 	}
-	if atomic.LoadInt32(&config.dumpProfilesOnStopEstablishTunnelsDone) != 0 {
+	if config.runningOnlyInproxyBroker {
+		// There will always be zero established clients when running only the
+		// in-proxy broker and no tunnel protocols.
+		return false
+	}
+	if atomic.LoadInt32(&config.dumpProfilesOnStopEstablishTunnelsDoneOnce) != 0 {
 		return false
 	}
 	dump := (establishedClientsCount <= config.stopEstablishTunnelsEstablishedClientThreshold)
-	atomic.StoreInt32(&config.dumpProfilesOnStopEstablishTunnelsDone, 1)
+	if dump {
+		atomic.StoreInt32(&config.dumpProfilesOnStopEstablishTunnelsDoneOnce, 1)
+	}
 	return dump
 }
 
@@ -584,6 +592,12 @@ func (config *Config) GetRunningProtocols() []string {
 	return config.runningProtocols
 }
 
+// GetRunningOnlyInproxyBroker indicates if the server is running only the
+// in-proxy broker and no tunnel protocols.
+func (config *Config) GetRunningOnlyInproxyBroker() bool {
+	return config.runningOnlyInproxyBroker
+}
+
 // LoadConfig loads and validates a JSON encoded server config.
 func LoadConfig(configJSON []byte) (*Config, error) {
 
@@ -627,6 +641,9 @@ func LoadConfig(configJSON []byte) (*Config, error) {
 		}
 	}
 
+	config.runningProtocols = []string{}
+	config.runningOnlyInproxyBroker = config.MeekServerRunInproxyBroker
+
 	for tunnelProtocol := range config.TunnelProtocolPorts {
 		if !common.Contains(protocol.SupportedTunnelProtocols, tunnelProtocol) {
 			return nil, errors.Tracef("Unsupported tunnel protocol: %s", tunnelProtocol)
@@ -693,6 +710,9 @@ func LoadConfig(configJSON []byte) (*Config, error) {
 					protocol.TUNNEL_PROTOCOL_FRONTED_MEEK)
 			}
 		}
+
+		config.runningProtocols = append(config.runningProtocols, tunnelProtocol)
+		config.runningOnlyInproxyBroker = false
 	}
 
 	for tunnelProtocol, address := range config.TunnelProtocolPassthroughAddresses {
@@ -796,11 +816,6 @@ func LoadConfig(configJSON []byte) (*Config, error) {
 		}
 	}
 
-	config.runningProtocols = []string{}
-	for tunnelProtocol := range config.TunnelProtocolPorts {
-		config.runningProtocols = append(config.runningProtocols, tunnelProtocol)
-	}
-
 	return &config, nil
 }
 

+ 5 - 1
psiphon/server/demux.go

@@ -68,7 +68,11 @@ type protocolClassifier struct {
 // Limitation: the conn is also closed after reading maxBytesToMatch and
 // failing to find a match, which can be a fingerprint for a raw conn with no
 // preceding anti-probing measure, such as TLS passthrough.
-func newProtocolDemux(ctx context.Context, listener net.Listener, classifiers []protocolClassifier, connClassificationTimeout time.Duration) (*protocolDemux, []protoListener) {
+func newProtocolDemux(
+	ctx context.Context,
+	listener net.Listener,
+	classifiers []protocolClassifier,
+	connClassificationTimeout time.Duration) (*protocolDemux, []protoListener) {
 
 	ctx, cancelFunc := context.WithCancel(ctx)
 

+ 3 - 1
psiphon/server/listener.go

@@ -125,7 +125,9 @@ func (listener *TacticsListener) accept() (net.Conn, error) {
 	// See the comment in server.LoadConfig regarding provider ID limitations.
 	if protocol.TunnelProtocolIsDirect(listener.tunnelProtocol) &&
 		common.ContainsAny(
-			p.KeyStrings(parameters.RestrictDirectProviderRegions, listener.support.Config.GetProviderID()), []string{"", listener.support.Config.GetRegion()}) {
+			p.KeyStrings(parameters.RestrictDirectProviderRegions,
+				listener.support.Config.GetProviderID()),
+			[]string{"", listener.support.Config.GetRegion()}) {
 
 		if p.WeightedCoinFlip(
 			parameters.RestrictDirectProviderIDsServerProbability) {

+ 38 - 1
psiphon/server/meek.go

@@ -351,6 +351,7 @@ func NewMeekServer(
 			&inproxy.BrokerConfig{
 				Logger:                         CommonLogger(log),
 				AllowProxy:                     meekServer.inproxyBrokerAllowProxy,
+				PrioritizeProxy:                meekServer.inproxyBrokerPrioritizeProxy,
 				AllowClient:                    meekServer.inproxyBrokerAllowClient,
 				AllowDomainFrontedDestinations: meekServer.inproxyBrokerAllowDomainFrontedDestinations,
 				LookupGeoIP:                    lookupGeoIPData,
@@ -358,6 +359,7 @@ func NewMeekServer(
 				APIParameterLogFieldFormatter:  getInproxyBrokerAPIParameterLogFieldFormatter(),
 				IsValidServerEntryTag:          support.PsinetDatabase.IsValidServerEntryTag,
 				GetTacticsPayload:              meekServer.inproxyBrokerGetTacticsPayload,
+				IsLoadLimiting:                 meekServer.support.TunnelServer.CheckLoadLimiting,
 				PrivateKey:                     sessionPrivateKey,
 				ObfuscationRootSecret:          obfuscationRootSecret,
 				ServerEntrySignaturePublicKey:  support.Config.InproxyBrokerServerEntrySignaturePublicKey,
@@ -1825,6 +1827,7 @@ func (server *MeekServer) inproxyReloadTactics() error {
 	if err != nil {
 		return errors.Trace(err)
 	}
+	defer p.Close()
 	if p.IsNil() {
 		return nil
 	}
@@ -1835,7 +1838,10 @@ func (server *MeekServer) inproxyReloadTactics() error {
 		return errors.Trace(err)
 	}
 
-	server.inproxyBroker.SetCommonCompartmentIDs(commonCompartmentIDs)
+	err = server.inproxyBroker.SetCommonCompartmentIDs(commonCompartmentIDs)
+	if err != nil {
+		return errors.Trace(err)
+	}
 
 	server.inproxyBroker.SetTimeouts(
 		p.Duration(parameters.InproxyBrokerProxyAnnounceTimeout),
@@ -1863,12 +1869,14 @@ func (server *MeekServer) inproxyReloadTactics() error {
 }
 
 func (server *MeekServer) lookupAllowTactic(geoIPData common.GeoIPData, parameterName string) bool {
+
 	// Fallback to not-allow on failure or nil tactics.
 	p, err := server.support.ServerTacticsParametersCache.Get(GeoIPData(geoIPData))
 	if err != nil {
 		log.WithTraceFields(LogFields{"error": err}).Warning("ServerTacticsParametersCache.Get failed")
 		return false
 	}
+	defer p.Close()
 	if p.IsNil() {
 		return false
 	}
@@ -1887,6 +1895,35 @@ func (server *MeekServer) inproxyBrokerAllowDomainFrontedDestinations(clientGeoI
 	return server.lookupAllowTactic(clientGeoIPData, parameters.InproxyAllowDomainFrontedDestinations)
 }
 
+func (server *MeekServer) inproxyBrokerPrioritizeProxy(
+	proxyGeoIPData common.GeoIPData, proxyAPIParams common.APIParameters) bool {
+
+	// Fallback to not-prioritized on failure or nil tactics.
+	p, err := server.support.ServerTacticsParametersCache.Get(GeoIPData(proxyGeoIPData))
+	if err != nil {
+		log.WithTraceFields(LogFields{"error": err}).Warning("ServerTacticsParametersCache.Get failed")
+		return false
+	}
+	defer p.Close()
+	if p.IsNil() {
+		return false
+	}
+	filter := p.KeyStringsValue(parameters.InproxyBrokerMatcherPrioritizeProxiesFilter)
+	if len(filter) == 0 {
+		return false
+	}
+	for name, values := range filter {
+		proxyValue, err := getStringRequestParam(proxyAPIParams, name)
+		if err != nil || !common.ContainsWildcard(values, proxyValue) {
+			return false
+		}
+	}
+	if !p.WeightedCoinFlip(parameters.InproxyBrokerMatcherPrioritizeProxiesProbability) {
+		return false
+	}
+	return true
+}
+
 // inproxyBrokerGetTacticsPayload is a callback used by the in-proxy broker to
 // provide tactics to proxies.
 //

+ 1 - 1
psiphon/server/replay.go

@@ -229,7 +229,7 @@ func (r *ReplayCache) SetReplayParameters(
 	r.cacheMutex.Lock()
 	defer r.cacheMutex.Unlock()
 
-	r.cache.Add(key, value, TTL)
+	r.cache.Set(key, value, TTL)
 
 	// go-cache-lru is typically safe for concurrent access but explicit
 	// synchronization is required when accessing Items. Items may include

+ 3 - 3
psiphon/server/server_test.go

@@ -1057,10 +1057,10 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 			if !ok {
 				t.Errorf("missing inproxy_broker.broker_event")
 			}
-			if event == "client_offer" || event == "proxy_announce" {
+			if event == "client-offer" || event == "proxy-announce" {
 				fronting_provider_id, ok := logFields["fronting_provider_id"].(string)
 				if !ok || fronting_provider_id != inproxyTestConfig.brokerFrontingProviderID {
-					t.Errorf("unexpected inproxy_broker.fronting_provider_id")
+					t.Errorf("unexpected inproxy_broker.fronting_provider_id for %s", event)
 				}
 			}
 		}
@@ -1965,7 +1965,7 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 
 		// Access the unexported controller.steeringIPCache
 		controllerStruct := reflect.ValueOf(controller).Elem()
-		steeringIPCacheField := controllerStruct.Field(40)
+		steeringIPCacheField := controllerStruct.FieldByName("steeringIPCache")
 		steeringIPCacheField = reflect.NewAt(
 			steeringIPCacheField.Type(), unsafe.Pointer(steeringIPCacheField.UnsafeAddr())).Elem()
 		steeringIPCache := steeringIPCacheField.Interface().(*lrucache.Cache)

+ 9 - 2
psiphon/server/services.go

@@ -315,10 +315,14 @@ func RunServices(configJSON []byte) (retErr error) {
 	// SIGUSR2 triggers an immediate load log and optional process profile output
 	logServerLoadSignal := makeSIGUSR2Channel()
 
-	// SIGTSTP triggers tunnelServer to stop establishing new tunnels
+	// SIGTSTP triggers tunnelServer to stop establishing new tunnels. The
+	// in-proxy broker, if running, may also stop enqueueing announces or
+	// offers.
 	stopEstablishingTunnelsSignal := makeSIGTSTPChannel()
 
-	// SIGCONT triggers tunnelServer to resume establishing new tunnels
+	// SIGCONT triggers tunnelServer to resume establishing new tunnels. The
+	// in-proxy broker, if running, will resume enqueueing all announces and
+	// offers.
 	resumeEstablishingTunnelsSignal := makeSIGCONTChannel()
 
 	err = nil
@@ -329,6 +333,9 @@ loop:
 		case <-stopEstablishingTunnelsSignal:
 			tunnelServer.SetEstablishTunnels(false)
 
+			// Dump profiles when entering the load limiting state with an
+			// unexpectedly low established client count, as determined by
+			// DumpProfilesOnStopEstablishTunnels.
 			if config.DumpProfilesOnStopEstablishTunnels(
 				tunnelServer.GetEstablishedClientCount()) {
 

+ 11 - 2
psiphon/server/tactics.go

@@ -135,6 +135,17 @@ func (c *ServerTacticsParametersCache) Get(
 
 	// Construct parameters from tactics.
 
+	// Note: since ServerTacticsParametersCache was implemented,
+	// tactics.Server.cachedTacticsData was added. That new cache is
+	// primarily intended to reduce server allocations and computations
+	// when _clients_ request tactics. cachedTacticsData also impacts
+	// GetTacticsWithTag.
+	//
+	// ServerTacticsParametersCache still optimizes performance for
+	// server-side tactics, since cachedTacticsData doesn't avoid filter
+	// checks, and ServerTacticsParametersCache includes a prepared
+	// parameters.ParametersAccessor.
+
 	tactics, tag, err := c.support.TacticsServer.GetTacticsWithTag(
 		true, common.GeoIPData(geoIPData), make(common.APIParameters))
 	if err != nil {
@@ -146,8 +157,6 @@ func (c *ServerTacticsParametersCache) Get(
 		return nilAccessor, nil
 	}
 
-	// Tactics.Probability is ignored for server-side tactics.
-
 	params, err := parameters.NewParameters(nil)
 	if err != nil {
 		return nilAccessor, errors.Trace(err)

+ 49 - 28
psiphon/server/tunnelServer.go

@@ -51,6 +51,7 @@ import (
 	"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/refraction"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/stacktrace"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/tactics"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/transforms"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/tun"
@@ -324,6 +325,13 @@ func (server *TunnelServer) CheckEstablishTunnels() bool {
 	return server.sshServer.checkEstablishTunnels()
 }
 
+// CheckLoadLimiting returns whether the server is in the load limiting state,
+// which is when EstablishTunnels is false. CheckLoadLimiting is intended to
+// be checked by non-tunnel components; no metrics are updated by this call.
+func (server *TunnelServer) CheckLoadLimiting() bool {
+	return server.sshServer.checkLoadLimiting()
+}
+
 // GetEstablishTunnelsMetrics returns whether tunnel establishment is
 // currently allowed and the number of tunnels rejected since due to not
 // establishing since the last GetEstablishTunnelsMetrics call.
@@ -525,6 +533,14 @@ func (sshServer *sshServer) checkEstablishTunnels() bool {
 	return establishTunnels
 }
 
+func (sshServer *sshServer) checkLoadLimiting() bool {
+
+	// The server is in a general load limiting state when
+	// sshServer.establishTunnels is false (0). This check is intended to be
+	// used by non-tunnel components and no metrics are updated by this call.
+	return atomic.LoadInt32(&sshServer.establishTunnels) == 0
+}
+
 func (sshServer *sshServer) getEstablishTunnelsMetrics() (bool, int64) {
 	return atomic.LoadInt32(&sshServer.establishTunnels) == 1,
 		atomic.SwapInt64(&sshServer.establishLimitedCount, 0)
@@ -538,6 +554,23 @@ type additionalTransportData struct {
 	steeringIP             string
 }
 
+// reportListenerError logs a listener error and sends it the
+// TunnelServer.Run. Callers should wrap the input err in an immediate
+// errors.Trace.
+func reportListenerError(listenerError chan<- error, err error) {
+
+	// Record "caller" just in case the caller fails to wrap err in an
+	// errors.Trace.
+	log.WithTraceFields(
+		LogFields{
+			"error":  err,
+			"caller": stacktrace.GetParentFunctionName()}).Error("listener error")
+	select {
+	case listenerError <- err:
+	default:
+	}
+}
+
 // runListener is intended to run an a goroutine; it blocks
 // running a particular listener. If an unrecoverable error
 // occurs, it will send the error to the listenerError channel.
@@ -609,10 +642,7 @@ func (sshServer *sshServer) runListener(sshListener *sshListener, listenerError
 			}
 
 			if err != nil {
-				select {
-				case listenerError <- errors.Trace(err):
-				default:
-				}
+				reportListenerError(listenerError, errors.Trace(err))
 				return
 			}
 		}
@@ -658,10 +688,7 @@ func (sshServer *sshServer) runMeekTLSOSSHDemuxListener(
 		sshListener.tunnelProtocol,
 		sshListener.port)
 	if err != nil {
-		select {
-		case listenerError <- errors.Trace(err):
-		default:
-		}
+		reportListenerError(listenerError, errors.Trace(err))
 		return
 	}
 
@@ -698,10 +725,7 @@ func (sshServer *sshServer) runMeekTLSOSSHDemuxListener(
 
 		err := mux.run()
 		if err != nil {
-			select {
-			case listenerError <- errors.Trace(err):
-			default:
-			}
+			reportListenerError(listenerError, errors.Trace(err))
 			return
 		}
 	}()
@@ -749,10 +773,7 @@ func (sshServer *sshServer) runMeekTLSOSSHDemuxListener(
 		}
 
 		if err != nil {
-			select {
-			case listenerError <- errors.Trace(err):
-			default:
-			}
+			reportListenerError(listenerError, errors.Trace(err))
 			return
 		}
 	}()
@@ -790,10 +811,7 @@ func runListener(
 				continue
 			}
 
-			select {
-			case listenerError <- errors.Trace(err):
-			default:
-			}
+			reportListenerError(listenerError, errors.Trace(err))
 			return
 		}
 
@@ -1440,8 +1458,10 @@ func (sshServer *sshServer) reloadTactics() error {
 			// for broker public keys no longer in the known/expected list;
 			// but will retain any existing sessions for broker public keys
 			// that remain in the list.
-			sshServer.inproxyBrokerSessions.SetKnownBrokerPublicKeys(brokerPublicKeys)
-
+			err = sshServer.inproxyBrokerSessions.SetKnownBrokerPublicKeys(brokerPublicKeys)
+			if err != nil {
+				return errors.Trace(err)
+			}
 		}
 	}
 
@@ -1538,7 +1558,7 @@ func (sshServer *sshServer) handleClient(
 					conn.Close()
 				})
 			}
-			io.Copy(ioutil.Discard, conn)
+			_, _ = io.Copy(ioutil.Discard, conn)
 			conn.Close()
 			afterFunc.Stop()
 
@@ -2301,7 +2321,8 @@ func (sshClient *sshClient) run(
 			// It is recommended to set ServerOSSHPrefixSpecs, etc., in default
 			// tactics.
 
-			p, err := sshClient.sshServer.support.ServerTacticsParametersCache.Get(sshClient.peerGeoIPData)
+			var p parameters.ParametersAccessor
+			p, err = sshClient.sshServer.support.ServerTacticsParametersCache.Get(sshClient.peerGeoIPData)
 
 			// Log error, but continue. A default prefix spec will be used by the server.
 			if err != nil {
@@ -2640,8 +2661,8 @@ func (sshClient *sshClient) authLogCallback(conn ssh.ConnMetadata, method string
 // I/O, as newly connecting clients need to await stop completion of any
 // existing connection that shares the same session ID.
 func (sshClient *sshClient) stop() {
-	sshClient.sshConn.Close()
-	sshClient.sshConn.Wait()
+	_ = sshClient.sshConn.Close()
+	_ = sshClient.sshConn.Wait()
 }
 
 // awaitStopped will block until sshClient.run has exited, at which point all
@@ -3662,7 +3683,7 @@ func (sshClient *sshClient) rejectNewChannel(newChannel ssh.NewChannel, logMessa
 	}
 
 	// Note: logMessage is internal, for logging only; just the reject reason is sent to the client.
-	newChannel.Reject(reason, reason.String())
+	_ = newChannel.Reject(reason, reason.String())
 }
 
 // setHandshakeState sets the handshake state -- that it completed and
@@ -4768,7 +4789,7 @@ func (sshClient *sshClient) handleTCPChannel(
 				return
 			}
 
-			newChannel.Reject(protocol.CHANNEL_REJECT_REASON_SPLIT_TUNNEL, "")
+			_ = newChannel.Reject(protocol.CHANNEL_REJECT_REASON_SPLIT_TUNNEL, "")
 			return
 		}
 	}

+ 12 - 4
psiphon/server/udp.go

@@ -367,7 +367,8 @@ type udpgwPortForward struct {
 
 var udpgwBufferPool = &sync.Pool{
 	New: func() any {
-		return make([]byte, udpgwProtocolMaxMessageSize)
+		b := make([]byte, udpgwProtocolMaxMessageSize)
+		return &b
 	},
 }
 
@@ -386,10 +387,17 @@ func (portForward *udpgwPortForward) relayDownstream() {
 	// TODO: is the buffer size larger than necessary?
 
 	// Use a buffer pool to minimize GC churn resulting from frequent,
-	// short-lived UDP flows, including DNS requests.
-	buffer := udpgwBufferPool.Get().([]byte)
+	// short-lived UDP flows, including DNS requests. A pointer to a slice is
+	// used with sync.Pool to avoid an allocation on Put, as would happen if
+	// passing in a slice instead of a pointer; see
+	// https://github.com/dominikh/go-tools/issues/1042#issuecomment-869064445
+	// and
+	// https://github.com/dominikh/go-tools/issues/1336#issuecomment-1331206290
+	// (which should not apply here).
+	b := udpgwBufferPool.Get().(*[]byte)
+	buffer := *b
 	clear(buffer)
-	defer udpgwBufferPool.Put(buffer)
+	defer udpgwBufferPool.Put(b)
 
 	packetBuffer := buffer[portForward.preambleSize:udpgwProtocolMaxMessageSize]
 	for {

+ 17 - 7
psiphon/serverApi.go

@@ -1014,7 +1014,7 @@ func (serverContext *ServerContext) getBaseAPIParameters(
 // included with each Psiphon API request. These common parameters are used
 // for metrics.
 //
-// The input dialPatrams may be nil when the filter has
+// The input dialParams may be nil when the filter has
 // baseParametersNoDialParameters.
 func getBaseAPIParameters(
 	filter baseParametersFilter,
@@ -1459,7 +1459,7 @@ func HandleOSLRequest(
 
 	defer func() {
 		if retErr != nil {
-			request.Reply(false, nil)
+			_ = request.Reply(false, nil)
 		}
 	}()
 
@@ -1470,7 +1470,11 @@ func HandleOSLRequest(
 	}
 
 	if oslRequest.ClearLocalSLOKs {
-		DeleteSLOKs()
+		err := DeleteSLOKs()
+		if err != nil {
+			NoticeWarning("DeleteSLOKs failed: %v", errors.Trace(err))
+			// Continue
+		}
 	}
 
 	seededNewSLOK := false
@@ -1479,7 +1483,7 @@ func HandleOSLRequest(
 		duplicate, err := SetSLOK(slok.ID, slok.Key)
 		if err != nil {
 			// TODO: return error to trigger retry?
-			NoticeWarning("SetSLOK failed: %s", errors.Trace(err))
+			NoticeWarning("SetSLOK failed: %v", errors.Trace(err))
 		} else if !duplicate {
 			seededNewSLOK = true
 		}
@@ -1493,7 +1497,10 @@ func HandleOSLRequest(
 		tunnelOwner.SignalSeededNewSLOK()
 	}
 
-	request.Reply(true, nil)
+	err = request.Reply(true, nil)
+	if err != nil {
+		return errors.Trace(err)
+	}
 
 	return nil
 }
@@ -1503,7 +1510,7 @@ func HandleAlertRequest(
 
 	defer func() {
 		if retErr != nil {
-			request.Reply(false, nil)
+			_ = request.Reply(false, nil)
 		}
 	}()
 
@@ -1517,7 +1524,10 @@ func HandleAlertRequest(
 		NoticeServerAlert(alertRequest)
 	}
 
-	request.Reply(true, nil)
+	err = request.Reply(true, nil)
+	if err != nil {
+		return errors.Trace(err)
+	}
 
 	return nil
 }

+ 11 - 7
psiphon/tactics.go

@@ -79,7 +79,7 @@ func GetTactics(ctx context.Context, config *Config, useStoredTactics bool) (fet
 			GetTacticsStorer(config),
 			config.GetNetworkID())
 		if err != nil {
-			NoticeWarning("get stored tactics failed: %s", err)
+			NoticeWarning("get stored tactics failed: %s", errors.Trace(err))
 
 			// The error will be due to a local datastore problem.
 			// While we could proceed with the tactics request, this
@@ -97,7 +97,7 @@ func GetTactics(ctx context.Context, config *Config, useStoredTactics bool) (fet
 
 		iterator, err := NewTacticsServerEntryIterator(config)
 		if err != nil {
-			NoticeWarning("tactics iterator failed: %s", err)
+			NoticeWarning("tactics iterator failed: %s", errors.Trace(err))
 			return
 		}
 		defer iterator.Close()
@@ -113,7 +113,7 @@ func GetTactics(ctx context.Context, config *Config, useStoredTactics bool) (fet
 
 			serverEntry, err := iterator.Next()
 			if err != nil {
-				NoticeWarning("tactics iterator failed: %s", err)
+				NoticeWarning("tactics iterator failed: %s", errors.Trace(err))
 				return
 			}
 
@@ -126,7 +126,11 @@ func GetTactics(ctx context.Context, config *Config, useStoredTactics bool) (fet
 					return
 				}
 
-				iterator.Reset()
+				err := iterator.Reset()
+				if err != nil {
+					NoticeWarning("tactics iterator failed: %s", errors.Trace(err))
+					return
+				}
 				continue
 			}
 
@@ -159,7 +163,7 @@ func GetTactics(ctx context.Context, config *Config, useStoredTactics bool) (fet
 				}
 			}
 
-			NoticeWarning("tactics request failed: %s", err)
+			NoticeWarning("tactics request failed: %s", errors.Trace(err))
 
 			// On error, proceed with a retry, as the error is likely
 			// due to a network failure.
@@ -190,7 +194,7 @@ func GetTactics(ctx context.Context, config *Config, useStoredTactics bool) (fet
 		err := config.SetParameters(
 			tacticsRecord.Tag, true, tacticsRecord.Tactics.Parameters)
 		if err != nil {
-			NoticeWarning("apply tactics failed: %s", err)
+			NoticeWarning("apply tactics failed: %s", errors.Trace(err))
 
 			// The error will be due to invalid tactics values from
 			// the server. When SetParameters fails, all
@@ -258,7 +262,7 @@ func fetchTactics(
 		return nil, errors.Tracef(
 			"failed to make dial parameters for %s: %v",
 			serverEntry.GetDiagnosticID(),
-			err)
+			errors.Trace(err))
 	}
 
 	NoticeRequestingTactics(dialParams)

+ 10 - 7
psiphon/tlsDialer.go

@@ -485,29 +485,32 @@ func CustomTLSDial(
 			return nil, errors.Trace(err)
 		}
 
-		ss := utls.MakeClientSessionState(
+		sessionState := utls.MakeClientSessionState(
 			obfuscatedSessionState.SessionTicket,
 			obfuscatedSessionState.Vers,
 			obfuscatedSessionState.CipherSuite,
 			obfuscatedSessionState.MasterSecret,
 			nil, nil)
-		ss.SetCreatedAt(obfuscatedSessionState.CreatedAt)
-		ss.SetEMS(obfuscatedSessionState.ExtMasterSecret)
+		sessionState.SetCreatedAt(obfuscatedSessionState.CreatedAt)
+		sessionState.SetEMS(obfuscatedSessionState.ExtMasterSecret)
 		// TLS 1.3-only fields
-		ss.SetAgeAdd(obfuscatedSessionState.AgeAdd)
-		ss.SetUseBy(obfuscatedSessionState.UseBy)
+		sessionState.SetAgeAdd(obfuscatedSessionState.AgeAdd)
+		sessionState.SetUseBy(obfuscatedSessionState.UseBy)
 
 		if isTLS13 {
 			// Sets OOB PSK if required.
 			if containsPSKExt(utlsClientHelloID, utlsClientHelloSpec) {
 				if wrappedCache, ok := clientSessionCache.(*common.UtlsClientSessionCacheWrapper); ok {
-					wrappedCache.Put("", ss)
+					wrappedCache.Put("", sessionState)
 				} else {
 					return nil, errors.TraceNew("unexpected clientSessionCache type")
 				}
 			}
 		} else {
-			conn.SetSessionState(ss)
+			err := conn.SetSessionState(sessionState)
+			if err != nil {
+				return nil, errors.Trace(err)
+			}
 		}
 
 		// Apply changes to utls

+ 33 - 24
psiphon/tunnel.go

@@ -258,30 +258,38 @@ func (tunnel *Tunnel) Activate(
 			go func() {
 				defer wg.Done()
 				notice := true
-				select {
-				case serverRequest := <-tunnel.sshServerRequests:
-					if serverRequest != nil {
-						if serverRequest.Type == protocol.PSIPHON_API_INPROXY_RELAY_REQUEST_NAME {
-
-							if notice {
-								NoticeInfo(
-									"relaying inproxy broker packets for %s",
-									tunnel.dialParams.ServerEntry.GetDiagnosticID())
-								notice = false
+				for {
+					select {
+					case serverRequest := <-tunnel.sshServerRequests:
+						if serverRequest != nil {
+							if serverRequest.Type == protocol.PSIPHON_API_INPROXY_RELAY_REQUEST_NAME {
+
+								if notice {
+									NoticeInfo(
+										"relaying inproxy broker packets for %s",
+										tunnel.dialParams.ServerEntry.GetDiagnosticID())
+									notice = false
+								}
+								err := tunnel.relayInproxyPacketRoundTrip(handshakeCtx, serverRequest)
+								if err != nil {
+									NoticeWarning(
+										"relay inproxy broker packets failed: %v",
+										errors.Trace(err))
+									// Continue
+								}
+
+							} else {
+
+								// There's a potential race condition in which
+								// post-handshake SSH requests, such as OSL or
+								// alert requests, arrive to this handler instead
+								// of operateTunnel, so invoke HandleServerRequest here.
+								HandleServerRequest(tunnelOwner, tunnel, serverRequest)
 							}
-							tunnel.relayInproxyPacketRoundTrip(handshakeCtx, serverRequest)
-
-						} else {
-
-							// There's a potential race condition in which
-							// post-handshake SSH requests, such as OSL or
-							// alert requests, arrive to this handler instead
-							// of operateTunnel, so invoke HandleServerRequest here.
-							HandleServerRequest(tunnelOwner, tunnel, serverRequest)
 						}
+					case <-handshakeCtx.Done():
+						return
 					}
-				case <-handshakeCtx.Done():
-					return
 				}
 			}()
 		}
@@ -363,7 +371,7 @@ func (tunnel *Tunnel) relayInproxyPacketRoundTrip(
 
 	defer func() {
 		if retErr != nil {
-			request.Reply(false, nil)
+			_ = request.Reply(false, nil)
 		}
 	}()
 
@@ -373,6 +381,9 @@ func (tunnel *Tunnel) relayInproxyPacketRoundTrip(
 
 	var relayRequest protocol.InproxyRelayRequest
 	err := cbor.Unmarshal(request.Payload, &relayRequest)
+	if err != nil {
+		return errors.Trace(err)
+	}
 
 	inproxyConn := tunnel.dialParams.inproxyConn.Load().(*inproxy.ClientConn)
 	if inproxyConn == nil {
@@ -1550,8 +1561,6 @@ func dialInproxy(
 	params := getBaseAPIParameters(
 		baseParametersNoDialParameters, true, config, nil)
 
-	common.LogFields(params).Add(dialParams.GetInproxyBrokerMetrics())
-
 	// The debugLogging flag is passed to both NoticeCommonLogger and to the
 	// inproxy package as well; skipping debug logs in the inproxy package,
 	// before calling into the notice logger, avoids unnecessary allocations

+ 2 - 0
psiphon/upgradeDownload.go

@@ -88,6 +88,7 @@ func DownloadUpgrade(
 	downloadURL := urls.Select(attempt)
 
 	payloadSecure := true
+	frontingUseDeviceBinder := true
 	httpClient, _, _, err := MakeDownloadHTTPClient(
 		ctx,
 		config,
@@ -97,6 +98,7 @@ func DownloadUpgrade(
 		config.DisableSystemRootCAs,
 		payloadSecure,
 		downloadURL.FrontingSpecs,
+		frontingUseDeviceBinder,
 		func(frontingProviderID string) {
 			NoticeInfo(
 				"DownloadUpgrade: selected fronting provider %s for %s",

部分文件因文件數量過多而無法顯示