Procházet zdrojové kódy

Merge pull request #559 from rod-hynes/master

Support for improving SendFeedback blocking resistance
Rod Hynes před 5 roky
rodič
revize
f930bc9249

+ 7 - 7
psiphon/common/parameters/clientParameters.go

@@ -372,8 +372,8 @@ var defaultClientParameters = map[string]struct {
 	FetchRemoteServerListRetryPeriod:   {value: 30 * time.Second, minimum: 1 * time.Millisecond},
 	FetchRemoteServerListStalePeriod:   {value: 6 * time.Hour, minimum: 1 * time.Hour},
 	RemoteServerListSignaturePublicKey: {value: ""},
-	RemoteServerListURLs:               {value: DownloadURLs{}},
-	ObfuscatedServerListRootURLs:       {value: DownloadURLs{}},
+	RemoteServerListURLs:               {value: TransferURLs{}},
+	ObfuscatedServerListRootURLs:       {value: TransferURLs{}},
 
 	PsiphonAPIRequestTimeout: {value: 20 * time.Second, minimum: 1 * time.Second, flags: useNetworkLatencyMultiplier},
 
@@ -399,7 +399,7 @@ var defaultClientParameters = map[string]struct {
 	FetchUpgradeTimeout:                {value: 60 * time.Second, minimum: 1 * time.Second, flags: useNetworkLatencyMultiplier},
 	FetchUpgradeRetryPeriod:            {value: 30 * time.Second, minimum: 1 * time.Millisecond},
 	FetchUpgradeStalePeriod:            {value: 6 * time.Hour, minimum: 1 * time.Hour},
-	UpgradeDownloadURLs:                {value: DownloadURLs{}},
+	UpgradeDownloadURLs:                {value: TransferURLs{}},
 	UpgradeDownloadClientVersionHeader: {value: ""},
 
 	TotalBytesTransferredNoticePeriod: {value: 5 * time.Minute, minimum: 1 * time.Second},
@@ -655,7 +655,7 @@ func (p *ClientParameters) Set(
 			// RemoteServerListURLs is set?
 
 			switch v := newValue.(type) {
-			case DownloadURLs:
+			case TransferURLs:
 				err := v.DecodeAndValidate()
 				if err != nil {
 					if skipOnError {
@@ -1079,9 +1079,9 @@ func (p ClientParametersAccessor) LabeledQUICVersions(name, label string) protoc
 	return value[label]
 }
 
-// DownloadURLs returns a DownloadURLs parameter value.
-func (p ClientParametersAccessor) DownloadURLs(name string) DownloadURLs {
-	value := DownloadURLs{}
+// TransferURLs returns a TransferURLs parameter value.
+func (p ClientParametersAccessor) TransferURLs(name string) TransferURLs {
+	value := TransferURLs{}
 	p.snapshot.getValue(name, &value)
 	return value
 }

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

@@ -93,10 +93,10 @@ func TestGetDefaultParameters(t *testing.T) {
 					t.Fatalf("LabeledQUICVersions returned %+v expected %+v", g, versions)
 				}
 			}
-		case DownloadURLs:
-			g := p.Get().DownloadURLs(name)
+		case TransferURLs:
+			g := p.Get().TransferURLs(name)
 			if !reflect.DeepEqual(v, g) {
-				t.Fatalf("DownloadURLs returned %+v expected %+v", g, v)
+				t.Fatalf("TransferURLs returned %+v expected %+v", g, v)
 			}
 		case common.RateLimits:
 			g := p.Get().RateLimits(name)

+ 27 - 28
psiphon/common/parameters/downloadURLs.go → psiphon/common/parameters/transferURLs.go

@@ -26,9 +26,9 @@ import (
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
 )
 
-// DownloadURL specifies a URL for downloading resources along with parameters
-// for the download strategy.
-type DownloadURL struct {
+// TransferURL specifies a URL for uploading or downloading resources along
+// with parameters for the transfer strategy.
+type TransferURL struct {
 
 	// URL is the location of the resource. This string is slightly obfuscated
 	// with base64 encoding to mitigate trivial binary executable string scanning.
@@ -39,81 +39,80 @@ type DownloadURL struct {
 	// only be set to true when the resource has its own verification mechanism.
 	SkipVerify bool
 
-	// OnlyAfterAttempts specifies how to schedule this URL when downloading
+	// OnlyAfterAttempts specifies how to schedule this URL when transferring
 	// the same resource (same entity, same ETag) from multiple different
 	// candidate locations. For a value of N, this URL is only a candidate
-	// after N rounds of attempting the download from other URLs.
+	// after N rounds of attempting the transfer to or from other URLs.
 	OnlyAfterAttempts int
 }
 
-// DownloadURLs is a list of download URLs.
-type DownloadURLs []*DownloadURL
+// TransferURLs is a list of transfer URLs.
+type TransferURLs []*TransferURL
 
 // DecodeAndValidate validates a list of download URLs.
 //
-// At least one DownloadURL in the list must have OnlyAfterAttempts of 0,
-// or no DownloadURL would be selected on the first attempt.
-func (d DownloadURLs) DecodeAndValidate() error {
+// At least one TransferURL in the list must have OnlyAfterAttempts of 0,
+// or no TransferURL would be selected on the first attempt.
+func (t TransferURLs) DecodeAndValidate() error {
 
 	hasOnlyAfterZero := false
-	for _, downloadURL := range d {
-		if downloadURL.OnlyAfterAttempts == 0 {
+	for _, transferURL := range t {
+		if transferURL.OnlyAfterAttempts == 0 {
 			hasOnlyAfterZero = true
 		}
-		decodedURL, err := base64.StdEncoding.DecodeString(downloadURL.URL)
+		decodedURL, err := base64.StdEncoding.DecodeString(transferURL.URL)
 		if err != nil {
 			return errors.Tracef("failed to decode URL: %s", err)
 		}
 
-		downloadURL.URL = string(decodedURL)
+		transferURL.URL = string(decodedURL)
 	}
 
 	if !hasOnlyAfterZero {
-		return errors.Tracef("must be at least one DownloadURL with OnlyAfterAttempts = 0")
+		return errors.Tracef("must be at least one TransferURL with OnlyAfterAttempts = 0")
 	}
 
 	return nil
 }
 
-// Select chooses a DownloadURL from the list.
+// Select chooses a TransferURL from the list.
 //
 // The first return value is the canonical URL, to be used
-// as a key when storing information related to the DownloadURLs,
+// as a key when storing information related to the TransferURLs,
 // such as an ETag.
 //
-// The second return value is the chosen download URL, which is
+// The second return value is the chosen transfer URL, which is
 // selected based at random from the candidates allowed in the
 // specified attempt.
-func (d DownloadURLs) Select(attempt int) (string, string, bool) {
+func (t TransferURLs) Select(attempt int) (string, string, bool) {
 
 	// The first OnlyAfterAttempts = 0 URL is the canonical URL. This
 	// is the value used as the key for SetUrlETag when multiple download
 	// URLs can be used to fetch a single entity.
 
 	canonicalURL := ""
-	for _, downloadURL := range d {
-		if downloadURL.OnlyAfterAttempts == 0 {
-			canonicalURL = downloadURL.URL
+	for _, transferURL := range t {
+		if transferURL.OnlyAfterAttempts == 0 {
+			canonicalURL = transferURL.URL
 			break
 		}
 	}
 
 	candidates := make([]int, 0)
-	for index, URL := range d {
+	for index, URL := range t {
 		if attempt >= URL.OnlyAfterAttempts {
 			candidates = append(candidates, index)
 		}
 	}
 
 	if len(candidates) < 1 {
-		// This case is not expected, as decodeAndValidateDownloadURLs
-		// should reject configs that would have no candidates for
-		// 0 attempts.
+		// This case is not expected, as DecodeAndValidate should reject configs
+		// that would have no candidates for 0 attempts.
 		return "", "", true
 	}
 
 	selection := prng.Intn(len(candidates))
-	downloadURL := d[candidates[selection]]
+	transferURL := t[candidates[selection]]
 
-	return downloadURL.URL, canonicalURL, downloadURL.SkipVerify
+	return transferURL.URL, canonicalURL, transferURL.SkipVerify
 }

+ 9 - 9
psiphon/common/parameters/downloadURLs_test.go → psiphon/common/parameters/transferURLs_test.go

@@ -24,7 +24,7 @@ import (
 	"testing"
 )
 
-func TestDownloadURLs(t *testing.T) {
+func TestTransferURLs(t *testing.T) {
 
 	decodedA := "a.example.com"
 	encodedA := base64.StdEncoding.EncodeToString([]byte(decodedA))
@@ -33,7 +33,7 @@ func TestDownloadURLs(t *testing.T) {
 
 	testCases := []struct {
 		description                string
-		downloadURLs               DownloadURLs
+		transferURLs               TransferURLs
 		attempts                   int
 		expectedValid              bool
 		expectedCanonicalURL       string
@@ -41,7 +41,7 @@ func TestDownloadURLs(t *testing.T) {
 	}{
 		{
 			"missing OnlyAfterAttempts = 0",
-			DownloadURLs{
+			TransferURLs{
 				{
 					URL:               encodedA,
 					OnlyAfterAttempts: 1,
@@ -54,7 +54,7 @@ func TestDownloadURLs(t *testing.T) {
 		},
 		{
 			"single URL, multiple attempts",
-			DownloadURLs{
+			TransferURLs{
 				{
 					URL:               encodedA,
 					OnlyAfterAttempts: 0,
@@ -67,7 +67,7 @@ func TestDownloadURLs(t *testing.T) {
 		},
 		{
 			"multiple URLs, single attempt",
-			DownloadURLs{
+			TransferURLs{
 				{
 					URL:               encodedA,
 					OnlyAfterAttempts: 0,
@@ -88,7 +88,7 @@ func TestDownloadURLs(t *testing.T) {
 		},
 		{
 			"multiple URLs, multiple attempts",
-			DownloadURLs{
+			TransferURLs{
 				{
 					URL:               encodedA,
 					OnlyAfterAttempts: 0,
@@ -109,7 +109,7 @@ func TestDownloadURLs(t *testing.T) {
 		},
 		{
 			"multiple URLs, multiple attempts",
-			DownloadURLs{
+			TransferURLs{
 				{
 					URL:               encodedA,
 					OnlyAfterAttempts: 0,
@@ -133,7 +133,7 @@ func TestDownloadURLs(t *testing.T) {
 	for _, testCase := range testCases {
 		t.Run(testCase.description, func(t *testing.T) {
 
-			err := testCase.downloadURLs.DecodeAndValidate()
+			err := testCase.transferURLs.DecodeAndValidate()
 
 			if testCase.expectedValid {
 				if err != nil {
@@ -159,7 +159,7 @@ func TestDownloadURLs(t *testing.T) {
 
 			attempt := 0
 			for i := 0; i < runs; i++ {
-				url, canonicalURL, skipVerify := testCase.downloadURLs.Select(attempt)
+				url, canonicalURL, skipVerify := testCase.transferURLs.Select(attempt)
 				if canonicalURL != testCase.expectedCanonicalURL {
 					t.Fatalf("unexpected canonical URL: %s", canonicalURL)
 				}

+ 10 - 10
psiphon/config.go

@@ -321,7 +321,7 @@ type Config struct {
 	// on the Psiphon Network, and is typically embedded in the client binary.
 	// All URLs must point to the same entity with the same ETag. At least one
 	// DownloadURL must have OnlyAfterAttempts = 0.
-	RemoteServerListURLs parameters.DownloadURLs
+	RemoteServerListURLs parameters.TransferURLs
 
 	// RemoteServerListSignaturePublicKey specifies a public key that's used
 	// to authenticate the remote server list payload. This value is supplied
@@ -344,7 +344,7 @@ type Config struct {
 	// embedded in the client binary. All URLs must point to the same entity
 	// with the same ETag. At least one DownloadURL must have
 	// OnlyAfterAttempts = 0.
-	ObfuscatedServerListRootURLs parameters.DownloadURLs
+	ObfuscatedServerListRootURLs parameters.TransferURLs
 
 	// SplitTunnelRoutesURLFormat is a URL which specifies the location of a
 	// routes file to use for split tunnel mode. The URL must include a
@@ -375,7 +375,7 @@ type Config struct {
 	// embedded in the client binary. All URLs must point to the same entity
 	// with the same ETag. At least one DownloadURL must have
 	// OnlyAfterAttempts = 0.
-	UpgradeDownloadURLs parameters.DownloadURLs
+	UpgradeDownloadURLs parameters.TransferURLs
 
 	// UpgradeDownloadClientVersionHeader specifies the HTTP header name for
 	// the entity at UpgradeDownloadURLs which specifies the client version
@@ -896,15 +896,15 @@ func (config *Config) Commit(migrateFromLegacyFields bool) error {
 	}
 
 	if config.RemoteServerListUrl != "" && config.RemoteServerListURLs == nil {
-		config.RemoteServerListURLs = promoteLegacyDownloadURL(config.RemoteServerListUrl)
+		config.RemoteServerListURLs = promoteLegacyTransferURL(config.RemoteServerListUrl)
 	}
 
 	if config.ObfuscatedServerListRootURL != "" && config.ObfuscatedServerListRootURLs == nil {
-		config.ObfuscatedServerListRootURLs = promoteLegacyDownloadURL(config.ObfuscatedServerListRootURL)
+		config.ObfuscatedServerListRootURLs = promoteLegacyTransferURL(config.ObfuscatedServerListRootURL)
 	}
 
 	if config.UpgradeDownloadUrl != "" && config.UpgradeDownloadURLs == nil {
-		config.UpgradeDownloadURLs = promoteLegacyDownloadURL(config.UpgradeDownloadUrl)
+		config.UpgradeDownloadURLs = promoteLegacyTransferURL(config.UpgradeDownloadUrl)
 	}
 
 	if config.TunnelProtocol != "" && len(config.LimitTunnelProtocols) == 0 {
@@ -1723,14 +1723,14 @@ func (config *Config) setDialParametersHash() {
 	config.dialParametersHash = hash.Sum(nil)
 }
 
-func promoteLegacyDownloadURL(URL string) parameters.DownloadURLs {
-	downloadURLs := make(parameters.DownloadURLs, 1)
-	downloadURLs[0] = &parameters.DownloadURL{
+func promoteLegacyTransferURL(URL string) parameters.TransferURLs {
+	transferURLs := make(parameters.TransferURLs, 1)
+	transferURLs[0] = &parameters.TransferURL{
 		URL:               base64.StdEncoding.EncodeToString([]byte(URL)),
 		SkipVerify:        false,
 		OnlyAfterAttempts: 0,
 	}
-	return downloadURLs
+	return transferURLs
 }
 
 type loggingDeviceBinder struct {

+ 5 - 213
psiphon/controller.go

@@ -25,7 +25,6 @@ package psiphon
 
 import (
 	"context"
-	std_errors "errors"
 	"fmt"
 	"math/rand"
 	"net"
@@ -38,7 +37,6 @@ import (
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
-	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/tactics"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/tun"
 )
 
@@ -1263,7 +1261,11 @@ func (controller *Controller) launchEstablishing() {
 		defer tacticsWaitPeriod.Stop()
 
 		controller.establishWaitGroup.Add(1)
-		go controller.getTactics(tacticsDone)
+		go func() {
+			defer controller.establishWaitGroup.Done()
+			defer close(tacticsDone)
+			GetTactics(controller.establishCtx, controller.config)
+		}()
 
 		select {
 		case <-tacticsDone:
@@ -1432,216 +1434,6 @@ func (controller *Controller) stopEstablishing() {
 	DoGarbageCollection()
 }
 
-func (controller *Controller) getTactics(done chan struct{}) {
-	defer controller.establishWaitGroup.Done()
-	defer close(done)
-
-	// Limitation: GetNetworkID may not account for device VPN status, so
-	// Psiphon-over-Psiphon or Psiphon-over-other-VPN scenarios can encounter
-	// this issue:
-	//
-	// 1. Tactics are established when tunneling through a VPN and egressing
-	//    through a remote region/ISP.
-	// 2. Psiphon is next run when _not_ tunneling through the VPN. Yet the
-	//    network ID remains the same. Initial applied tactics will be for the
-	//    remote egress region/ISP, not the local region/ISP.
-
-	tacticsRecord, err := tactics.UseStoredTactics(
-		GetTacticsStorer(),
-		controller.config.GetNetworkID())
-	if err != nil {
-		NoticeWarning("get stored tactics failed: %s", err)
-
-		// The error will be due to a local datastore problem.
-		// While we could proceed with the tactics request, this
-		// could result in constant tactics requests. So, abort.
-		return
-	}
-
-	if tacticsRecord == nil {
-
-		iterator, err := NewTacticsServerEntryIterator(
-			controller.config)
-		if err != nil {
-			NoticeWarning("tactics iterator failed: %s", err)
-			return
-		}
-		defer iterator.Close()
-
-		for iteration := 0; ; iteration++ {
-
-			if !WaitForNetworkConnectivity(
-				controller.runCtx,
-				controller.config.NetworkConnectivityChecker) {
-				return
-			}
-
-			serverEntry, err := iterator.Next()
-			if err != nil {
-				NoticeWarning("tactics iterator failed: %s", err)
-				return
-			}
-
-			if serverEntry == nil {
-				if iteration == 0 {
-					NoticeWarning("tactics request skipped: no capable servers")
-					return
-				}
-
-				iterator.Reset()
-				continue
-			}
-
-			tacticsRecord, err = controller.doFetchTactics(serverEntry)
-			if err == nil {
-				break
-			}
-
-			NoticeWarning("tactics request failed: %s", err)
-
-			// On error, proceed with a retry, as the error is likely
-			// due to a network failure.
-			//
-			// TODO: distinguish network and local errors and abort
-			// on local errors.
-
-			p := controller.config.GetClientParameters().Get()
-			timeout := prng.JitterDuration(
-				p.Duration(parameters.TacticsRetryPeriod),
-				p.Float(parameters.TacticsRetryPeriodJitter))
-			p.Close()
-
-			tacticsRetryDelay := time.NewTimer(timeout)
-
-			select {
-			case <-controller.establishCtx.Done():
-				return
-			case <-tacticsRetryDelay.C:
-			}
-
-			tacticsRetryDelay.Stop()
-		}
-	}
-
-	if tacticsRecord != nil &&
-		prng.FlipWeightedCoin(tacticsRecord.Tactics.Probability) {
-
-		err := controller.config.SetClientParameters(
-			tacticsRecord.Tag, true, tacticsRecord.Tactics.Parameters)
-		if err != nil {
-			NoticeWarning("apply tactics failed: %s", err)
-
-			// The error will be due to invalid tactics values from
-			// the server. When ApplyClientParameters fails, all
-			// previous tactics values are left in place. Abort
-			// without retry since the server is highly unlikely
-			// to return different values immediately.
-			return
-		}
-	}
-
-	// Reclaim memory from the completed tactics request as we're likely
-	// to be proceeding to the memory-intensive tunnel establishment phase.
-	DoGarbageCollection()
-	emitMemoryMetrics()
-}
-
-func (controller *Controller) doFetchTactics(
-	serverEntry *protocol.ServerEntry) (*tactics.Record, error) {
-
-	canReplay := func(serverEntry *protocol.ServerEntry, replayProtocol string) bool {
-		return common.Contains(
-			serverEntry.GetSupportedTacticsProtocols(), replayProtocol)
-	}
-
-	selectProtocol := func(serverEntry *protocol.ServerEntry) (string, bool) {
-		tacticsProtocols := serverEntry.GetSupportedTacticsProtocols()
-		if len(tacticsProtocols) == 0 {
-			return "", false
-		}
-		index := prng.Intn(len(tacticsProtocols))
-		return tacticsProtocols[index], true
-	}
-
-	dialParams, err := MakeDialParameters(
-		controller.config,
-		canReplay,
-		selectProtocol,
-		serverEntry,
-		true,
-		0,
-		0)
-	if dialParams == nil {
-		// MakeDialParameters may return nil, nil when the server entry can't
-		// satisfy protocol selection criteria. This case in not expected
-		// since NewTacticsServerEntryIterator should only return tactics-
-		// capable server entries and selectProtocol will select any tactics
-		// protocol.
-		err = std_errors.New("failed to make dial parameters")
-	}
-	if err != nil {
-		return nil, errors.Trace(err)
-	}
-
-	NoticeRequestingTactics(dialParams)
-
-	// TacticsTimeout should be a very long timeout, since it's not
-	// adjusted by tactics in a new network context, and so clients
-	// with very slow connections must be accomodated. This long
-	// timeout will not entirely block the beginning of tunnel
-	// establishment, which beings after the shorter TacticsWaitPeriod.
-	//
-	// Using controller.establishCtx will cancel FetchTactics
-	// if tunnel establishment completes first.
-
-	timeout := controller.config.GetClientParameters().Get().Duration(
-		parameters.TacticsTimeout)
-
-	ctx, cancelFunc := context.WithTimeout(
-		controller.establishCtx,
-		timeout)
-	defer cancelFunc()
-
-	// DialMeek completes the TCP/TLS handshakes for HTTPS
-	// meek protocols but _not_ for HTTP meek protocols.
-	//
-	// TODO: pre-dial HTTP protocols to conform with speed
-	// test RTT spec.
-	//
-	// TODO: ensure that meek in round trip mode will fail
-	// the request when the pre-dial connection is broken,
-	// to minimize the possibility of network ID mismatches.
-
-	meekConn, err := DialMeek(
-		ctx, dialParams.GetMeekConfig(), dialParams.GetDialConfig())
-	if err != nil {
-		return nil, errors.Trace(err)
-	}
-	defer meekConn.Close()
-
-	apiParams := getBaseAPIParameters(
-		baseParametersAll, controller.config, dialParams)
-
-	tacticsRecord, err := tactics.FetchTactics(
-		ctx,
-		controller.config.clientParameters,
-		GetTacticsStorer(),
-		controller.config.GetNetworkID,
-		apiParams,
-		serverEntry.Region,
-		dialParams.TunnelProtocol,
-		serverEntry.TacticsRequestPublicKey,
-		serverEntry.TacticsRequestObfuscatedKey,
-		meekConn.RoundTrip)
-	if err != nil {
-		return nil, errors.Trace(err)
-	}
-
-	NoticeRequestedTactics(dialParams)
-
-	return tacticsRecord, nil
-}
-
 // establishCandidateGenerator populates the candidate queue with server entries
 // from the data store. Server entries are iterated in rank order, so that promoted
 // servers with higher rank are priority candidates.

+ 2 - 2
psiphon/remoteServerList.go

@@ -55,7 +55,7 @@ func FetchCommonRemoteServerList(
 
 	p := config.GetClientParameters().Get()
 	publicKey := p.String(parameters.RemoteServerListSignaturePublicKey)
-	urls := p.DownloadURLs(parameters.RemoteServerListURLs)
+	urls := p.TransferURLs(parameters.RemoteServerListURLs)
 	downloadTimeout := p.Duration(parameters.FetchRemoteServerListTimeout)
 	p.Close()
 
@@ -146,7 +146,7 @@ func FetchObfuscatedServerLists(
 
 	p := config.GetClientParameters().Get()
 	publicKey := p.String(parameters.RemoteServerListSignaturePublicKey)
-	urls := p.DownloadURLs(parameters.ObfuscatedServerListRootURLs)
+	urls := p.TransferURLs(parameters.ObfuscatedServerListRootURLs)
 	downloadTimeout := p.Duration(parameters.FetchRemoteServerListTimeout)
 	p.Close()
 

+ 248 - 0
psiphon/tactics.go

@@ -0,0 +1,248 @@
+/*
+ * Copyright (c) 2020, 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 (
+	"context"
+	std_errors "errors"
+	"time"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/tactics"
+)
+
+// GetTactics attempts to apply tactics, for the current network, to the given
+// config. GetTactics first checks for unexpired stored tactics, which it will
+// immediately return. If no unexpired stored tactics are found, tactics
+// requests are attempted until the input context is cancelled.
+//
+// Callers are responsible for ensuring that the input context eventually
+// cancels, and should synchronize GetTactics calls to ensure no unintended
+// concurrent fetch attempts occur.
+func GetTactics(ctx context.Context, config *Config) {
+
+	// Limitation: GetNetworkID may not account for device VPN status, so
+	// Psiphon-over-Psiphon or Psiphon-over-other-VPN scenarios can encounter
+	// this issue:
+	//
+	// 1. Tactics are established when tunneling through a VPN and egressing
+	//    through a remote region/ISP.
+	// 2. Psiphon is next run when _not_ tunneling through the VPN. Yet the
+	//    network ID remains the same. Initial applied tactics will be for the
+	//    remote egress region/ISP, not the local region/ISP.
+
+	tacticsRecord, err := tactics.UseStoredTactics(
+		GetTacticsStorer(),
+		config.GetNetworkID())
+	if err != nil {
+		NoticeWarning("get stored tactics failed: %s", err)
+
+		// The error will be due to a local datastore problem.
+		// While we could proceed with the tactics request, this
+		// could result in constant tactics requests. So, abort.
+		return
+	}
+
+	if tacticsRecord == nil {
+
+		iterator, err := NewTacticsServerEntryIterator(config)
+		if err != nil {
+			NoticeWarning("tactics iterator failed: %s", err)
+			return
+		}
+		defer iterator.Close()
+
+		for iteration := 0; ; iteration++ {
+
+			if !WaitForNetworkConnectivity(
+				ctx, config.NetworkConnectivityChecker) {
+				return
+			}
+
+			serverEntry, err := iterator.Next()
+			if err != nil {
+				NoticeWarning("tactics iterator failed: %s", err)
+				return
+			}
+
+			if serverEntry == nil {
+				if iteration == 0 {
+					NoticeWarning("tactics request skipped: no capable servers")
+					return
+				}
+
+				iterator.Reset()
+				continue
+			}
+
+			tacticsRecord, err = fetchTactics(
+				ctx, config, serverEntry)
+			if err == nil {
+				break
+			}
+
+			NoticeWarning("tactics request failed: %s", err)
+
+			// On error, proceed with a retry, as the error is likely
+			// due to a network failure.
+			//
+			// TODO: distinguish network and local errors and abort
+			// on local errors.
+
+			p := config.GetClientParameters().Get()
+			timeout := prng.JitterDuration(
+				p.Duration(parameters.TacticsRetryPeriod),
+				p.Float(parameters.TacticsRetryPeriodJitter))
+			p.Close()
+
+			tacticsRetryDelay := time.NewTimer(timeout)
+
+			select {
+			case <-ctx.Done():
+				return
+			case <-tacticsRetryDelay.C:
+			}
+
+			tacticsRetryDelay.Stop()
+		}
+	}
+
+	if tacticsRecord != nil &&
+		prng.FlipWeightedCoin(tacticsRecord.Tactics.Probability) {
+
+		err := config.SetClientParameters(
+			tacticsRecord.Tag, true, tacticsRecord.Tactics.Parameters)
+		if err != nil {
+			NoticeWarning("apply tactics failed: %s", err)
+
+			// The error will be due to invalid tactics values from
+			// the server. When ApplyClientParameters fails, all
+			// previous tactics values are left in place. Abort
+			// without retry since the server is highly unlikely
+			// to return different values immediately.
+			return
+		}
+	}
+
+	// Reclaim memory from the completed tactics request as we're likely
+	// to be proceeding to the memory-intensive tunnel establishment phase.
+	DoGarbageCollection()
+	emitMemoryMetrics()
+}
+
+func fetchTactics(
+	ctx context.Context,
+	config *Config,
+	serverEntry *protocol.ServerEntry) (*tactics.Record, error) {
+
+	canReplay := func(serverEntry *protocol.ServerEntry, replayProtocol string) bool {
+		return common.Contains(
+			serverEntry.GetSupportedTacticsProtocols(), replayProtocol)
+	}
+
+	selectProtocol := func(serverEntry *protocol.ServerEntry) (string, bool) {
+		tacticsProtocols := serverEntry.GetSupportedTacticsProtocols()
+		if len(tacticsProtocols) == 0 {
+			return "", false
+		}
+		index := prng.Intn(len(tacticsProtocols))
+		return tacticsProtocols[index], true
+	}
+
+	dialParams, err := MakeDialParameters(
+		config,
+		canReplay,
+		selectProtocol,
+		serverEntry,
+		true,
+		0,
+		0)
+	if dialParams == nil {
+		// MakeDialParameters may return nil, nil when the server entry can't
+		// satisfy protocol selection criteria. This case in not expected
+		// since NewTacticsServerEntryIterator should only return tactics-
+		// capable server entries and selectProtocol will select any tactics
+		// protocol.
+		err = std_errors.New("failed to make dial parameters")
+	}
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+
+	NoticeRequestingTactics(dialParams)
+
+	// TacticsTimeout should be a very long timeout, since it's not
+	// adjusted by tactics in a new network context, and so clients
+	// with very slow connections must be accomodated. This long
+	// timeout will not entirely block the beginning of tunnel
+	// establishment, which beings after the shorter TacticsWaitPeriod.
+	//
+	// Using controller.establishCtx will cancel FetchTactics
+	// if tunnel establishment completes first.
+
+	timeout := config.GetClientParameters().Get().Duration(
+		parameters.TacticsTimeout)
+
+	ctx, cancelFunc := context.WithTimeout(ctx, timeout)
+	defer cancelFunc()
+
+	// DialMeek completes the TCP/TLS handshakes for HTTPS
+	// meek protocols but _not_ for HTTP meek protocols.
+	//
+	// TODO: pre-dial HTTP protocols to conform with speed
+	// test RTT spec.
+	//
+	// TODO: ensure that meek in round trip mode will fail
+	// the request when the pre-dial connection is broken,
+	// to minimize the possibility of network ID mismatches.
+
+	meekConn, err := DialMeek(
+		ctx, dialParams.GetMeekConfig(), dialParams.GetDialConfig())
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+	defer meekConn.Close()
+
+	apiParams := getBaseAPIParameters(
+		baseParametersAll, config, dialParams)
+
+	tacticsRecord, err := tactics.FetchTactics(
+		ctx,
+		config.clientParameters,
+		GetTacticsStorer(),
+		config.GetNetworkID,
+		apiParams,
+		serverEntry.Region,
+		dialParams.TunnelProtocol,
+		serverEntry.TacticsRequestPublicKey,
+		serverEntry.TacticsRequestObfuscatedKey,
+		meekConn.RoundTrip)
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+
+	NoticeRequestedTactics(dialParams)
+
+	return tacticsRecord, nil
+}

+ 1 - 1
psiphon/upgradeDownload.go

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