Преглед изворни кода

Use common fronting dial parameters

- Merge inproxy and fronted HTTP client fronting dial parameters
  implementations
- Add replay to fronted TransferURLs
Miro пре 1 година
родитељ
комит
d9b410a4f4

+ 9 - 0
psiphon/common/parameters/parameters.go

@@ -372,6 +372,10 @@ const (
 	SteeringIPCacheMaxEntries                          = "SteeringIPCacheMaxEntries"
 	SteeringIPCacheMaxEntries                          = "SteeringIPCacheMaxEntries"
 	SteeringIPProbability                              = "SteeringIPProbability"
 	SteeringIPProbability                              = "SteeringIPProbability"
 	ServerDiscoveryStrategy                            = "ServerDiscoveryStrategy"
 	ServerDiscoveryStrategy                            = "ServerDiscoveryStrategy"
+	TransferURLReplayDialParametersTTL                 = "TransferURLReplayDialParametersTTL"
+	TransferURLReplayUpdateFrequency                   = "TransferURLReplayUpdateFrequency"
+	TransferURLReplayDialParametersProbability         = "TransferURLReplayDialParametersProbability"
+	TransferURLReplayRetainFailedProbability           = "TransferURLReplayRetainFailedProbability"
 	InproxyAllowProxy                                  = "InproxyAllowProxy"
 	InproxyAllowProxy                                  = "InproxyAllowProxy"
 	InproxyAllowClient                                 = "InproxyAllowClient"
 	InproxyAllowClient                                 = "InproxyAllowClient"
 	InproxyAllowDomainFrontedDestinations              = "InproxyAllowDomainFrontedDestinations"
 	InproxyAllowDomainFrontedDestinations              = "InproxyAllowDomainFrontedDestinations"
@@ -876,6 +880,11 @@ var defaultParameters = map[string]struct {
 
 
 	ServerDiscoveryStrategy: {value: "", flags: serverSideOnly},
 	ServerDiscoveryStrategy: {value: "", flags: serverSideOnly},
 
 
+	TransferURLReplayDialParametersTTL:         {value: 24 * time.Hour, minimum: time.Duration(0)},
+	TransferURLReplayUpdateFrequency:           {value: 5 * time.Minute, minimum: time.Duration(0)},
+	TransferURLReplayDialParametersProbability: {value: 1.0, minimum: 0.0},
+	TransferURLReplayRetainFailedProbability:   {value: 0.5, minimum: 0.0},
+
 	// For inproxy tactics, there is no proxyOnly flag, since Psiphon apps may
 	// For inproxy tactics, there is no proxyOnly flag, since Psiphon apps may
 	// run both clients and inproxy proxies.
 	// run both clients and inproxy proxies.
 	//
 	//

+ 23 - 10
psiphon/feedback.go

@@ -119,13 +119,6 @@ func SendFeedback(ctx context.Context, config *Config, diagnostics, uploadPath s
 	defer resolver.Stop()
 	defer resolver.Stop()
 	config.SetResolver(resolver)
 	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
 	// Limitation: GetTactics will fail silently if the datastore used for
 	// retrieving and storing tactics is opened by another process. This can
 	// 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
 	// 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
 	//   or a network ID of "VPN" if some other non-Psiphon VPN is running
 	//   (the caller should ensure a network ID of "VPN" in this case).
 	//   (the caller should ensure a network ID of "VPN" in this case).
 
 
-	GetTactics(getTacticsCtx, config, 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
 	// Get the latest client parameters
-	p = config.GetParameters().Get()
+	p := config.GetParameters().Get()
 	feedbackUploadMinRetryDelay := p.Duration(parameters.FeedbackUploadRetryMinDelaySeconds)
 	feedbackUploadMinRetryDelay := p.Duration(parameters.FeedbackUploadRetryMinDelaySeconds)
 	feedbackUploadMaxRetryDelay := p.Duration(parameters.FeedbackUploadRetryMaxDelaySeconds)
 	feedbackUploadMaxRetryDelay := p.Duration(parameters.FeedbackUploadRetryMaxDelaySeconds)
 	feedbackUploadTimeout := p.Duration(parameters.FeedbackUploadTimeoutSeconds)
 	feedbackUploadTimeout := p.Duration(parameters.FeedbackUploadTimeoutSeconds)
@@ -204,15 +206,26 @@ func SendFeedback(ctx context.Context, config *Config, diagnostics, uploadPath s
 			feedbackUploadTimeout)
 			feedbackUploadTimeout)
 		defer cancelFunc()
 		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
 		payloadSecure := true
 		client, _, err := MakeUntunneledHTTPClient(
 		client, _, err := MakeUntunneledHTTPClient(
 			feedbackUploadCtx,
 			feedbackUploadCtx,
 			config,
 			config,
-			untunneledDialConfig,
+			dialConfig,
 			uploadURL.SkipVerify,
 			uploadURL.SkipVerify,
 			config.DisableSystemRootCAs,
 			config.DisableSystemRootCAs,
 			payloadSecure,
 			payloadSecure,
 			uploadURL.FrontingSpecs,
 			uploadURL.FrontingSpecs,
+			frontingUseDeviceBinder,
 			func(frontingProviderID string) {
 			func(frontingProviderID string) {
 				NoticeInfo(
 				NoticeInfo(
 					"SendFeedback: selected fronting provider %s for %s",
 					"SendFeedback: selected fronting provider %s for %s",

+ 202 - 1
psiphon/feedback_test.go

@@ -21,10 +21,27 @@ package psiphon
 
 
 import (
 import (
 	"context"
 	"context"
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/tls"
+	"crypto/x509"
+	"encoding/base64"
 	"encoding/json"
 	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
 	"io/ioutil"
 	"io/ioutil"
+	"net/http"
+	"os"
 	"os/exec"
 	"os/exec"
+	"strings"
 	"testing"
 	"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 {
 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")
 	configFileContents, err := ioutil.ReadFile("controller_test.config")
 	if err != nil {
 	if err != nil {
 		// Skip, don't fail, if config file is not present
 		// 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)
 		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)
+	}
+}

+ 311 - 0
psiphon/frontedHTTPClientInstance.go

@@ -0,0 +1,311 @@
+package psiphon
+
+import (
+	"bytes"
+	"context"
+	"encoding/binary"
+	"fmt"
+	"io"
+	"net/http"
+	"sync"
+	"time"
+
+	"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) {
+
+	// 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, TransferURLReplayDialParametersTTL,
+	// is 0.
+	now := time.Now()
+	ttl := p.Duration(parameters.TransferURLReplayDialParametersTTL)
+	networkID := config.GetNetworkID()
+
+	// Replay is disabled if there is an active tunnel.
+	replayEnabled := tunnel == nil &&
+		ttl > 0 &&
+		!config.DisableReplay &&
+		prng.FlipWeightedCoin(p.Float(parameters.TransferURLReplayDialParametersProbability))
+
+	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[prng.Intn(len(frontingSpecs)-1)]
+	}
+
+	// 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.prepareDialConfig(
+			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.TransferURLReplayRetainFailedProbability),
+		replayUpdateFrequency:         p.Duration(parameters.TransferURLReplayUpdateFrequency),
+	}, 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)
+
+	if response.StatusCode == http.StatusOK {
+		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("StoreFrontedHTTPDialParameters 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("DeleteFrontedHTTPDialParameters 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[:]
+}

+ 172 - 0
psiphon/frontedHTTPClientInstance_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")
+	}
+}

+ 612 - 0
psiphon/frontingDialParameters.go

@@ -0,0 +1,612 @@
+package psiphon
+
+import (
+	"context"
+	"net"
+	"net/http"
+	"strconv"
+	"sync/atomic"
+	"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/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"
+)
+
+// 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 TransferURLs.
+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.prepareDialConfig(
+		config,
+		p,
+		false,
+		tunnel,
+		skipVerify,
+		disableSystemRootCAs,
+		useDeviceBinder,
+		payloadSecure)
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+
+	return dialParams, nil
+}
+
+// prepareDialConfig is called for both new and replayed dial parameters.
+func (dialParams *frontedHTTPDialParameters) prepareDialConfig(config *Config,
+	p parameters.ParametersAccessor,
+	isReplay bool,
+	tunnel *Tunnel,
+	useDeviceBinder,
+	skipVerify,
+	disableSystemRootCAs,
+	payloadSecure bool) error {
+
+	dialParams.isReplay = isReplay
+
+	// Initialize DialConfig to be passed to the corresponding dialers.
+
+	if isReplay {
+		err := dialParams.FrontedMeekDialParameters.prepareDialConfig(
+			config, p, tunnel, nil, useDeviceBinder, skipVerify,
+			disableSystemRootCAs, payloadSecure)
+		if err != nil {
+			return errors.Trace(err)
+		}
+	}
+
+	return nil
+}
+
+// FrontedMeekDialParameters represents a selected fronting transport and dial
+// parameters.
+//
+// FrontedMeekDialParameters is used to configure dialers; and to report dial
+// stats in notices and Psiphon API calls.
+//
+// FrontedMeekDialParameters is similar to tunnel DialParameters, but is
+// specific to the in-proxy broker dial phase and TransferURLs.
+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 DialConfig to be passed to the corresponding dialers.
+
+	err = frontedMeekDialParams.prepareDialConfig(
+		config, p, tunnel, dialCustomHeaders, useDeviceBinder, skipVerify,
+		disableSystemRootCAs, payloadSecure)
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+
+	return &frontedMeekDialParams, nil
+}
+
+// prepareDialConfig is called for both new and replayed dial parameters.
+func (f *FrontedMeekDialParameters) prepareDialConfig(
+	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
+	//
+	// 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.
+
+	// 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 implements the common.MetricsSource interface and returns log
+// fields detailing the fronted meek dial parameters.
+func (meekDialParameters *FrontedMeekDialParameters) GetMetrics() common.LogFields {
+
+	logFields := make(common.LogFields)
+
+	logFields["fronting_provider_id"] = meekDialParameters.FrontingProviderID
+
+	if meekDialParameters.DialAddress != "" {
+		logFields["meek_dial_address"] = meekDialParameters.DialAddress
+	}
+
+	meekResolvedIPAddress := meekDialParameters.resolvedIPAddress.Load().(string)
+	if meekResolvedIPAddress != "" {
+		logFields["meek_resolved_ip_address"] = meekResolvedIPAddress
+	}
+
+	if meekDialParameters.SNIServerName != "" {
+		logFields["meek_sni_server_name"] = meekDialParameters.SNIServerName
+	}
+
+	if meekDialParameters.HostHeader != "" {
+		logFields["meek_host_header"] = meekDialParameters.HostHeader
+	}
+
+	transformedHostName := "0"
+	if meekDialParameters.TransformedHostName {
+		transformedHostName = "1"
+	}
+	logFields["meek_transformed_host_name"] = transformedHostName
+
+	if meekDialParameters.SelectedUserAgent {
+		logFields["user_agent"] = meekDialParameters.UserAgent
+	}
+
+	if meekDialParameters.FrontingTransport == protocol.FRONTING_TRANSPORT_HTTPS {
+
+		if meekDialParameters.TLSProfile != "" {
+			logFields["tls_profile"] = meekDialParameters.TLSProfile
+		}
+
+		if meekDialParameters.TLSVersion != "" {
+			logFields["tls_version"] =
+				getTLSVersionForMetrics(meekDialParameters.TLSVersion, meekDialParameters.NoDefaultTLSSessionID)
+		}
+
+		tlsFragmented := "0"
+		if meekDialParameters.TLSFragmentClientHello {
+			tlsFragmented = "1"
+		}
+		logFields["tls_fragmented"] = tlsFragmented
+	}
+
+	if meekDialParameters.BPFProgramName != "" {
+		logFields["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["dns_preresolved"] = meekDialParameters.ResolveParameters.PreresolvedIPAddress
+			}
+		}
+
+		if meekDialParameters.ResolveParameters.PreferAlternateDNSServer {
+			logFields["dns_preferred"] = meekDialParameters.ResolveParameters.AlternateDNSServer
+		}
+
+		if meekDialParameters.ResolveParameters.ProtocolTransformName != "" {
+			logFields["dns_transform"] = meekDialParameters.ResolveParameters.ProtocolTransformName
+		}
+
+		logFields["dns_attempt"] = strconv.Itoa(
+			meekDialParameters.ResolveParameters.GetFirstAttemptWithAnswer())
+	}
+
+	// TODO: get fragmentor metrics, if any, from MeekConn.
+
+	return logFields
+}

+ 67 - 350
psiphon/inproxy.go

@@ -30,6 +30,7 @@ import (
 	"net/http"
 	"net/http"
 	"net/netip"
 	"net/netip"
 	"strconv"
 	"strconv"
+	"strings"
 	"sync"
 	"sync"
 	"sync/atomic"
 	"sync/atomic"
 	"syscall"
 	"syscall"
@@ -37,15 +38,11 @@ import (
 
 
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
 	"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/inproxy"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters"
 	"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/prng"
-	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/resolver"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/resolver"
-	utls "github.com/Psiphon-Labs/utls"
 	"github.com/cespare/xxhash"
 	"github.com/cespare/xxhash"
-	"golang.org/x/net/bpf"
 )
 )
 
 
 // InproxyBrokerClientManager manages an InproxyBrokerClientInstance, an
 // InproxyBrokerClientManager manages an InproxyBrokerClientInstance, an
@@ -428,6 +425,8 @@ func NewInproxyBrokerClientInstance(
 				brokerSpecs,
 				brokerSpecs,
 				func(spec *parameters.InproxyBrokerSpec) string { return spec.BrokerPublicKey },
 				func(spec *parameters.InproxyBrokerSpec) string { return spec.BrokerPublicKey },
 				func(spec *parameters.InproxyBrokerSpec, dialParams *InproxyBrokerDialParameters) bool {
 				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)) &&
 					return dialParams.LastUsedTimestamp.After(now.Add(-ttl)) &&
 						bytes.Equal(dialParams.LastUsedBrokerSpecHash, hashBrokerSpec(spec))
 						bytes.Equal(dialParams.LastUsedBrokerSpecHash, hashBrokerSpec(spec))
 				})
 				})
@@ -448,6 +447,12 @@ func NewInproxyBrokerClientInstance(
 
 
 	isReplay := brokerDialParams != nil
 	isReplay := brokerDialParams != nil
 
 
+	// Handle legacy replay records by discarding replay when required fields
+	// are missing.
+	if isReplay && brokerDialParams.FrontedHTTPDialParameters == nil {
+		isReplay = false
+	}
+
 	if !isReplay {
 	if !isReplay {
 		brokerDialParams, err = MakeInproxyBrokerDialParameters(config, p, networkID, brokerSpec)
 		brokerDialParams, err = MakeInproxyBrokerDialParameters(config, p, networkID, brokerSpec)
 		if err != nil {
 		if err != nil {
@@ -455,7 +460,7 @@ func NewInproxyBrokerClientInstance(
 		}
 		}
 	} else {
 	} else {
 		brokerDialParams.brokerSpec = brokerSpec
 		brokerDialParams.brokerSpec = brokerSpec
-		err := brokerDialParams.prepareDialConfigs(config, p, networkID, true, nil)
+		err := brokerDialParams.prepareDialConfig(config, p, true)
 		if err != nil {
 		if err != nil {
 			return nil, errors.Trace(err)
 			return nil, errors.Trace(err)
 		}
 		}
@@ -536,7 +541,7 @@ func NewInproxyBrokerClientInstance(
 	// Adjust long-polling request timeouts to respect any maximum request
 	// Adjust long-polling request timeouts to respect any maximum request
 	// timeout supported by the provider fronting the request.
 	// timeout supported by the provider fronting the request.
 	maxRequestTimeout, ok := p.KeyDurations(
 	maxRequestTimeout, ok := p.KeyDurations(
-		parameters.InproxyFrontingProviderClientMaxRequestTimeouts)[brokerDialParams.FrontingProviderID]
+		parameters.InproxyFrontingProviderClientMaxRequestTimeouts)[brokerDialParams.FrontedHTTPDialParameters.FrontingProviderID]
 	if ok && maxRequestTimeout > 0 {
 	if ok && maxRequestTimeout > 0 {
 		if b.announceRequestTimeout > maxRequestTimeout {
 		if b.announceRequestTimeout > maxRequestTimeout {
 			b.announceRequestTimeout = maxRequestTimeout
 			b.announceRequestTimeout = maxRequestTimeout
@@ -857,7 +862,7 @@ func (b *InproxyBrokerClientInstance) BrokerClientRoundTripperSucceeded(roundTri
 
 
 	resolver := b.config.GetResolver()
 	resolver := b.config.GetResolver()
 	if resolver != nil {
 	if resolver != nil {
-		resolver.VerifyCacheExtension(b.brokerDialParams.FrontingDialAddress)
+		resolver.VerifyCacheExtension(b.brokerDialParams.FrontedHTTPDialParameters.FrontingDialAddress)
 	}
 	}
 }
 }
 
 
@@ -1049,39 +1054,7 @@ type InproxyBrokerDialParameters struct {
 	LastUsedTimestamp      time.Time
 	LastUsedTimestamp      time.Time
 	LastUsedBrokerSpecHash []byte
 	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.
 // MakeInproxyBrokerDialParameters creates a new InproxyBrokerDialParameters.
@@ -1091,12 +1064,6 @@ func MakeInproxyBrokerDialParameters(
 	networkID string,
 	networkID string,
 	brokerSpec *parameters.InproxyBrokerSpec) (*InproxyBrokerDialParameters, error) {
 	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() {
 	if config.UseUpstreamProxy() {
 		return nil, errors.TraceNew("upstream proxy unsupported")
 		return nil, errors.TraceNew("upstream proxy unsupported")
 	}
 	}
@@ -1113,141 +1080,36 @@ func MakeInproxyBrokerDialParameters(
 		LastUsedBrokerSpecHash: hashBrokerSpec(brokerSpec),
 		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 {
 	if err != nil {
 		return nil, errors.Trace(err)
 		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.
+	// Initialize DialConfig to be passed to the corresponding dialers.
 
 
-	err = brokerDialParams.prepareDialConfigs(config, p, networkID, false, dialCustomHeaders)
+	err = brokerDialParams.prepareDialConfig(
+		config,
+		p,
+		false)
 	if err != nil {
 	if err != nil {
 		return nil, errors.Trace(err)
 		return nil, errors.Trace(err)
 	}
 	}
@@ -1255,111 +1117,30 @@ func MakeInproxyBrokerDialParameters(
 	return brokerDialParams, nil
 	return brokerDialParams, nil
 }
 }
 
 
-// prepareDialConfigs is called for both new and replayed broker dial parameters.
-func (brokerDialParams *InproxyBrokerDialParameters) prepareDialConfigs(
+// prepareDialConfig is called for both new and replayed broker dial parameters.
+func (brokerDialParams *InproxyBrokerDialParameters) prepareDialConfig(
 	config *Config,
 	config *Config,
 	p parameters.ParametersAccessor,
 	p parameters.ParametersAccessor,
-	networkID string,
-	isReplay bool,
-	dialCustomHeaders http.Header) error {
+	isReplay bool) error {
 
 
 	brokerDialParams.isReplay = isReplay
 	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.prepareDialConfig(
+			config, p, nil, nil, true, skipVerify,
+			config.DisableSystemRootCAs, payloadSecure)
+		if err != nil {
+			return errors.Trace(err)
+		}
 	}
 	}
 
 
 	return nil
 	return nil
@@ -1375,7 +1156,7 @@ func (brokerDialParams *InproxyBrokerDialParameters) GetBrokerMetrics() common.L
 	// the broker -- as successful parameters might not otherwise by logged
 	// the broker -- as successful parameters might not otherwise by logged
 	// via server_tunnel if the subsequent WebRTC dials fail.
 	// via server_tunnel if the subsequent WebRTC dials fail.
 
 
-	logFields["fronting_provider_id"] = brokerDialParams.FrontingProviderID
+	logFields["fronting_provider_id"] = brokerDialParams.FrontedHTTPDialParameters.FrontingProviderID
 
 
 	return logFields
 	return logFields
 }
 }
@@ -1386,7 +1167,17 @@ func (brokerDialParams *InproxyBrokerDialParameters) GetMetrics() common.LogFiel
 
 
 	logFields := common.LogFields{}
 	logFields := common.LogFields{}
 
 
-	logFields["inproxy_broker_transport"] = brokerDialParams.BrokerTransport
+	// Add underlying log fields, which must be renamed to be scoped to the
+	// broker.
+	for k, v := range brokerDialParams.FrontedHTTPDialParameters.GetMetrics() {
+		// 1. Remove meek prefix, if any
+		name, _ := strings.CutPrefix(k, "meek_")
+		// 2. Add inproxy broker prefix
+		name = "inproxy_broker_" + name
+		logFields[name] = v
+	}
+
+	logFields["inproxy_broker_transport"] = brokerDialParams.FrontedHTTPDialParameters.FrontingTransport
 
 
 	isReplay := "0"
 	isReplay := "0"
 	if brokerDialParams.isReplay {
 	if brokerDialParams.isReplay {
@@ -1394,80 +1185,6 @@ func (brokerDialParams *InproxyBrokerDialParameters) GetMetrics() common.LogFiel
 	}
 	}
 	logFields["inproxy_broker_is_replay"] = isReplay
 	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
 	return logFields
 }
 }
 
 
@@ -1625,8 +1342,8 @@ func (rt *InproxyBrokerRoundTripper) RoundTrip(
 
 
 		conn, err := DialMeek(
 		conn, err := DialMeek(
 			requestCtx,
 			requestCtx,
-			rt.brokerDialParams.meekConfig,
-			rt.brokerDialParams.dialConfig)
+			rt.brokerDialParams.FrontedHTTPDialParameters.meekConfig,
+			rt.brokerDialParams.FrontedHTTPDialParameters.dialConfig)
 
 
 		if err != nil && ctx.Err() != context.Canceled {
 		if err != nil && ctx.Err() != context.Canceled {
 
 
@@ -1673,7 +1390,7 @@ func (rt *InproxyBrokerRoundTripper) RoundTrip(
 	// MeekConn in favor of the MeekDialConfig, while the path will be used.
 	// MeekConn in favor of the MeekDialConfig, while the path will be used.
 	url := fmt.Sprintf(
 	url := fmt.Sprintf(
 		"https://%s/%s",
 		"https://%s/%s",
-		rt.brokerDialParams.DialAddress,
+		rt.brokerDialParams.FrontedHTTPDialParameters.DialAddress,
 		inproxy.BrokerEndPointName)
 		inproxy.BrokerEndPointName)
 
 
 	request, err := http.NewRequestWithContext(
 	request, err := http.NewRequestWithContext(

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

+ 37 - 257
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/errors"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/fragmentor"
 	"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/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"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/resolver"
 	utls "github.com/Psiphon-Labs/utls"
 	utls "github.com/Psiphon-Labs/utls"
 	"golang.org/x/net/bpf"
 	"golang.org/x/net/bpf"
@@ -410,258 +408,34 @@ func UntunneledResolveIP(
 // payloadSecure must only be set if all HTTP plaintext payloads sent through
 // 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
 // the returned net/http.Client will be wrapped in their own transport security
 // layer, which permits skipping of server certificate verification.
 // 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(
 func makeFrontedHTTPClient(
-	ctx context.Context,
 	config *Config,
 	config *Config,
-	tunneled bool,
-	dialConfig *DialConfig,
+	tunnel *Tunnel,
 	frontingSpecs parameters.FrontingSpecs,
 	frontingSpecs parameters.FrontingSpecs,
 	selectedFrontingProviderID func(string),
 	selectedFrontingProviderID func(string),
+	useDeviceBinder,
 	skipVerify,
 	skipVerify,
 	disableSystemRootCAs,
 	disableSystemRootCAs,
 	payloadSecure bool) (*http.Client, func() common.APIParameters, error) {
 	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()
+	frontedHTTPClient, err := newFrontedHTTPClientInstance(
+		config, tunnel, frontingSpecs, selectedFrontingProviderID,
+		useDeviceBinder, skipVerify, disableSystemRootCAs, payloadSecure)
 	if err != nil {
 	if err != nil {
 		return nil, nil, errors.Trace(err)
 		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)
-	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 {
+	getParams := func() common.APIParameters {
 		params := make(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
+		for k, v := range frontedHTTPClient.frontedHTTPDialParameters.FrontedMeekDialParameters.GetMetrics() {
+			params[k] = v
 		}
 		}
-
-		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
 		return params
 	}
 	}
 
 
 	return &http.Client{
 	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
 // meekHTTPResponseReadCloser wraps an http.Response.Body received over a
@@ -708,19 +482,24 @@ func MakeUntunneledHTTPClient(
 	disableSystemRootCAs bool,
 	disableSystemRootCAs bool,
 	payloadSecure bool,
 	payloadSecure bool,
 	frontingSpecs parameters.FrontingSpecs,
 	frontingSpecs parameters.FrontingSpecs,
+	frontingUseDeviceBinder bool,
 	selectedFrontingProviderID func(string)) (*http.Client, func() common.APIParameters, error) {
 	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 {
 	if len(frontingSpecs) > 0 {
 
 
 		// Ignore skipVerify because it only applies when there are no
 		// Ignore skipVerify because it only applies when there are no
 		// fronting specs.
 		// fronting specs.
 		httpClient, getParams, err := makeFrontedHTTPClient(
 		httpClient, getParams, err := makeFrontedHTTPClient(
-			ctx,
 			config,
 			config,
-			false,
-			untunneledDialConfig,
+			nil,
 			frontingSpecs,
 			frontingSpecs,
 			selectedFrontingProviderID,
 			selectedFrontingProviderID,
+			frontingUseDeviceBinder,
 			false,
 			false,
 			disableSystemRootCAs,
 			disableSystemRootCAs,
 			payloadSecure)
 			payloadSecure)
@@ -778,32 +557,17 @@ func MakeTunneledHTTPClient(
 	// Note: there is no dial context since SSH port forward dials cannot
 	// Note: there is no dial context since SSH port forward dials cannot
 	// be interrupted directly. Closing the tunnel will interrupt the dials.
 	// 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 {
 	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
 		// Ignore skipVerify because it only applies when there are no
 		// fronting specs.
 		// fronting specs.
 		httpClient, getParams, err := makeFrontedHTTPClient(
 		httpClient, getParams, err := makeFrontedHTTPClient(
-			ctx,
 			config,
 			config,
-			true,
-			dialConfig,
+			tunnel,
 			frontingSpecs,
 			frontingSpecs,
 			selectedFrontingProviderID,
 			selectedFrontingProviderID,
 			false,
 			false,
+			false,
 			disableSystemRootCAs,
 			disableSystemRootCAs,
 			payloadSecure)
 			payloadSecure)
 		if err != nil {
 		if err != nil {
@@ -812,6 +576,13 @@ func MakeTunneledHTTPClient(
 		return httpClient, getParams, nil
 		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{
 	transport := &http.Transport{
 		Dial: tunneledDialer,
 		Dial: tunneledDialer,
 	}
 	}
@@ -851,6 +622,7 @@ func MakeDownloadHTTPClient(
 	disableSystemRootCAs,
 	disableSystemRootCAs,
 	payloadSecure bool,
 	payloadSecure bool,
 	frontingSpecs parameters.FrontingSpecs,
 	frontingSpecs parameters.FrontingSpecs,
+	frontingUseDeviceBinder bool,
 	selectedFrontingProviderID func(string)) (*http.Client, bool, func() common.APIParameters, error) {
 	selectedFrontingProviderID func(string)) (*http.Client, bool, func() common.APIParameters, error) {
 
 
 	var httpClient *http.Client
 	var httpClient *http.Client
@@ -875,14 +647,22 @@ func MakeDownloadHTTPClient(
 		}
 		}
 
 
 	} else {
 	} else {
+
+		var dialConfig *DialConfig
+		if len(frontingSpecs) == 0 {
+			// Must only set DialConfig if there are no fronting specs.
+			dialConfig = untunneledDialConfig
+		}
+
 		httpClient, getParams, err = MakeUntunneledHTTPClient(
 		httpClient, getParams, err = MakeUntunneledHTTPClient(
 			ctx,
 			ctx,
 			config,
 			config,
-			untunneledDialConfig,
+			dialConfig,
 			skipVerify,
 			skipVerify,
 			disableSystemRootCAs,
 			disableSystemRootCAs,
 			payloadSecure,
 			payloadSecure,
 			frontingSpecs,
 			frontingSpecs,
+			frontingUseDeviceBinder,
 			selectedFrontingProviderID)
 			selectedFrontingProviderID)
 		if err != nil {
 		if err != nil {
 			return nil, false, nil, errors.Trace(err)
 			return nil, false, nil, errors.Trace(err)

+ 2 - 0
psiphon/remoteServerList.go

@@ -462,6 +462,7 @@ func downloadRemoteServerListFile(
 	// or untunneled configuration.
 	// or untunneled configuration.
 
 
 	payloadSecure := true
 	payloadSecure := true
+	frontingUseDeviceBinder := true
 	httpClient, tunneled, getParams, err := MakeDownloadHTTPClient(
 	httpClient, tunneled, getParams, err := MakeDownloadHTTPClient(
 		ctx,
 		ctx,
 		config,
 		config,
@@ -471,6 +472,7 @@ func downloadRemoteServerListFile(
 		disableSystemRootCAs,
 		disableSystemRootCAs,
 		payloadSecure,
 		payloadSecure,
 		frontingSpecs,
 		frontingSpecs,
+		frontingUseDeviceBinder,
 		func(frontingProviderID string) {
 		func(frontingProviderID string) {
 			NoticeInfo(
 			NoticeInfo(
 				"downloadRemoteServerListFile: selected fronting provider %s for %s",
 				"downloadRemoteServerListFile: selected fronting provider %s for %s",

+ 2 - 0
psiphon/upgradeDownload.go

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