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

tactics: initial implementation

Rod Hynes 8 лет назад
Родитель
Сommit
6eb3f1577a
42 измененных файлов с 5465 добавлено и 1327 удалено
  1. 4 0
      .travis.yml
  2. 3 1
      ConsoleClient/main.go
  3. 5 5
      psiphon/TCPConn.go
  4. 41 0
      psiphon/common/api.go
  5. 667 0
      psiphon/common/parameters/clientParameters.go
  6. 197 0
      psiphon/common/parameters/clientParameters_test.go
  7. 122 0
      psiphon/common/parameters/downloadURLs.go
  8. 188 0
      psiphon/common/parameters/downloadURLs_test.go
  9. 19 5
      psiphon/common/protocol/protocol.go
  10. 47 18
      psiphon/common/protocol/serverEntry.go
  11. 1478 0
      psiphon/common/tactics/tactics.go
  12. 727 0
      psiphon/common/tactics/tactics_test.go
  13. 29 8
      psiphon/common/utils.go
  14. 38 0
      psiphon/common/utils_test.go
  15. 341 506
      psiphon/config.go
  16. 0 166
      psiphon/config_test.go
  17. 402 88
      psiphon/controller.go
  18. 28 19
      psiphon/controller_test.go
  19. 130 41
      psiphon/dataStore.go
  20. 5 2
      psiphon/feedback.go
  21. 10 5
      psiphon/httpProxy.go
  22. 11 4
      psiphon/interrupt_dials_test.go
  23. 310 153
      psiphon/meekConn.go
  24. 12 5
      psiphon/net.go
  25. 3 3
      psiphon/notice.go
  26. 27 14
      psiphon/remoteServerList.go
  27. 1 1
      psiphon/remoteServerList_test.go
  28. 121 57
      psiphon/server/api.go
  29. 4 0
      psiphon/server/config.go
  30. 56 22
      psiphon/server/meek.go
  31. 7 0
      psiphon/server/meek_test.go
  32. 3 3
      psiphon/server/safetyNet.go
  33. 1 1
      psiphon/server/server_test.go
  34. 18 1
      psiphon/server/services.go
  35. 12 3
      psiphon/server/tunnelServer.go
  36. 3 3
      psiphon/server/webServer.go
  37. 81 14
      psiphon/serverApi.go
  38. 42 26
      psiphon/splitTunnel.go
  39. 10 1
      psiphon/tlsDialer.go
  40. 238 122
      psiphon/tunnel.go
  41. 14 10
      psiphon/upgradeDownload.go
  42. 10 20
      psiphon/userAgentPicker.go

+ 4 - 0
.travis.yml

@@ -13,7 +13,9 @@ script:
 - go test -race -v ./common
 - go test -race -v ./common/accesscontrol
 - go test -race -v ./common/osl
+- go test -race -v ./common/parameters
 - go test -race -v ./common/protocol
+- go test -race -v ./common/tactics
 - go test -race -v -run TestObfuscatedSessionTicket ./common/tls
 # TODO: enable once known race condition is addressed
 #       also, see comment below
@@ -25,7 +27,9 @@ script:
 - go test -v -covermode=count -coverprofile=common.coverprofile ./common
 - go test -v -covermode=count -coverprofile=accesscontrol.coverprofile ./common/accesscontrol
 - go test -v -covermode=count -coverprofile=osl.coverprofile ./common/osl
+- go test -v -covermode=count -coverprofile=parameters.coverprofile ./common/parameters
 - go test -v -covermode=count -coverprofile=protocol.coverprofile ./common/protocol
+- go test -v -covermode=count -coverprofile=tactics.coverprofile ./common/tactics
 # TODO: fix and reenable test, which is failing in TravisCI environment:
 # --- FAIL: TestTunneledTCPIPv4
 #    tun_test.go:226: startTestTCPClient failed: syscall.Connect failed: connection timed out

+ 3 - 1
ConsoleClient/main.go

@@ -35,6 +35,7 @@ import (
 
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon"
 	"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/protocol"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/tun"
 )
@@ -241,7 +242,8 @@ func main() {
 			}
 		}()
 
-		if psiphon.CountServerEntries(config.EgressRegion, config.TunnelProtocol) == 0 {
+		limitTunnelProtocols := config.parameters.GetTunnelProtocols(parameters.LimitTunnelProtocols)
+		if psiphon.CountServerEntries(config.EgressRegion, limitTunnelProtocol) == 0 {
 			embeddedServerListWaitGroup.Wait()
 		} else {
 			defer embeddedServerListWaitGroup.Wait()

+ 5 - 5
psiphon/TCPConn.go

@@ -32,7 +32,7 @@ import (
 
 // TCPConn is a customized TCP connection that supports the Closer interface
 // and which may be created using options in DialConfig, including
-// UpstreamProxyUrl, DeviceBinder, IPv6Synthesizer, and ResolvedIPCallback.
+// UpstreamProxyURL, DeviceBinder, IPv6Synthesizer, and ResolvedIPCallback.
 // DeviceBinder is implemented using SO_BINDTODEVICE/IP_BOUND_IF, which
 // requires syscall-level socket code.
 type TCPConn struct {
@@ -42,7 +42,7 @@ type TCPConn struct {
 
 // NewTCPDialer creates a TCPDialer.
 //
-// Note: do not set an UpstreamProxyUrl in the config when using NewTCPDialer
+// Note: do not set an UpstreamProxyURL in the config when using NewTCPDialer
 // as a custom dialer for NewProxyAuthTransport (or http.Transport with a
 // ProxyUrl), as that would result in double proxy chaining.
 func NewTCPDialer(config *DialConfig) Dialer {
@@ -61,7 +61,7 @@ func DialTCP(
 	var conn net.Conn
 	var err error
 
-	if config.UpstreamProxyUrl != "" {
+	if config.UpstreamProxyURL != "" {
 		conn, err = proxiedTcpDial(ctx, addr, config)
 	} else {
 		conn, err = tcpDial(ctx, addr, config)
@@ -73,7 +73,7 @@ func DialTCP(
 
 	// Note: when an upstream proxy is used, we don't know what IP address
 	// was resolved, by the proxy, for that destination.
-	if config.ResolvedIPCallback != nil && config.UpstreamProxyUrl == "" {
+	if config.ResolvedIPCallback != nil && config.UpstreamProxyURL == "" {
 		ipAddress := common.IPAddressFromAddr(conn.RemoteAddr())
 		if ipAddress != "" {
 			config.ResolvedIPCallback(ipAddress)
@@ -111,7 +111,7 @@ func proxiedTcpDial(
 	upstreamDialer := upstreamproxy.NewProxyDialFunc(
 		&upstreamproxy.UpstreamProxyConfig{
 			ForwardDialFunc: dialer,
-			ProxyURIString:  config.UpstreamProxyUrl,
+			ProxyURIString:  config.UpstreamProxyURL,
 			CustomHeaders:   config.CustomHeaders,
 		})
 

+ 41 - 0
psiphon/common/api.go

@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2018, 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 common
+
+// APIParameters is a set of API parameter values, typically received
+// from a Psiphon client and used/logged by the Psiphon server. The
+// values are of varying types: strings, ints, arrays, structs, etc.
+type APIParameters map[string]interface{}
+
+// APIParameterValidator is a function that validates API parameters
+// for a particular request or context.
+type APIParameterValidator func(APIParameters) error
+
+// GeoIPData is type-compatible with psiphon/server.GeoIPData.
+type GeoIPData struct {
+	Country        string
+	City           string
+	ISP            string
+	DiscoveryValue int
+}
+
+// APIParameterLogFieldFormatter is a function that returns formatted
+// LogFields containing the given GeoIPData and APIParameters.
+type APIParameterLogFieldFormatter func(GeoIPData, APIParameters) LogFields

+ 667 - 0
psiphon/common/parameters/clientParameters.go

@@ -0,0 +1,667 @@
+/*
+ * Copyright (c) 2018, 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 parameters implements dynamic, concurrency-safe parameters that
+determine Psiphon client behavior.
+
+Parameters include network timeouts, probabilities for actions, lists of
+protocols, etc. Parameters are initialized with reasonable defaults. New
+values may be applied, allowing the client to customized its parameters from
+both a config file and tactics data. Sane minimum values are enforced.
+
+Parameters may be read and updated concurrently. The read mechanism offers a
+snapshot so that related parameters, such as two Ints representing a range; or
+a more complex series of related parameters; may be read in an atomic and
+consistent way. For example:
+
+    p := clientParameters.Get()
+    min := p.Int("Min")
+    max := p.Int("Max")
+    p = nil
+
+For long-running operations, it is recommended to set any pointer to the
+snapshot to nil to allow garbage collection of old snaphots in cases where the
+parameters change.
+
+In general, client parameters should be read as close to the point of use as
+possible to ensure that dynamic changes to the parameter values take effect.
+
+For duration parameters, time.ParseDuration-compatible string values are
+supported when applying new values. This allows specifying durations as, for
+example, "100ms" or "24h".
+
+Values read from the parameters are not deep copies and must be treated as
+read-only.
+*/
+package parameters
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"reflect"
+	"sync/atomic"
+	"time"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
+)
+
+const (
+	NetworkLatencyMultiplier                       = "NetworkLatencyMultiplier"
+	TacticsWaitPeriod                              = "TacticsWaitPeriod"
+	TacticsRetryPeriod                             = "TacticsRetryPeriod"
+	TacticsRetryPeriodJitter                       = "TacticsRetryPeriodJitter"
+	TacticsTimeout                                 = "TacticsTimeout"
+	ConnectionWorkerPoolSize                       = "ConnectionWorkerPoolSize"
+	TunnelConnectTimeout                           = "TunnelConnectTimeout"
+	EstablishTunnelTimeout                         = "EstablishTunnelTimeout"
+	EstablishTunnelWorkTime                        = "EstablishTunnelWorkTime"
+	EstablishTunnelPausePeriod                     = "EstablishTunnelPausePeriod"
+	EstablishTunnelPausePeriodJitter               = "EstablishTunnelPausePeriodJitter"
+	EstablishTunnelServerAffinityGracePeriod       = "EstablishTunnelServerAffinityGracePeriod"
+	StaggerConnectionWorkersPeriod                 = "StaggerConnectionWorkersPeriod"
+	StaggerConnectionWorkersJitter                 = "StaggerConnectionWorkersJitter"
+	LimitMeekConnectionWorkers                     = "LimitMeekConnectionWorkers"
+	IgnoreHandshakeStatsRegexps                    = "IgnoreHandshakeStatsRegexps"
+	PrioritizeTunnelProtocols                      = "PrioritizeTunnelProtocols"
+	PrioritizeTunnelProtocolsCandidateCount        = "PrioritizeTunnelProtocolsCandidateCount"
+	LimitTunnelProtocols                           = "LimitTunnelProtocols"
+	TunnelOperateShutdownTimeout                   = "TunnelOperateShutdownTimeout"
+	TunnelPortForwardDialTimeout                   = "TunnelPortForwardDialTimeout"
+	TunnelRateLimits                               = "TunnelRateLimits"
+	AdditionalCustomHeaders                        = "AdditionalCustomHeaders"
+	SpeedTestPaddingMinBytes                       = "SpeedTestPaddingMinBytes"
+	SpeedTestPaddingMaxBytes                       = "SpeedTestPaddingMaxBytes"
+	SpeedTestMaxSampleCount                        = "SpeedTestMaxSampleCount"
+	SSHKeepAliveSpeedTestSampleProbability         = "SSHKeepAliveSpeedTestSampleProbability"
+	SSHKeepAlivePaddingMinBytes                    = "SSHKeepAlivePaddingMinBytes"
+	SSHKeepAlivePaddingMaxBytes                    = "SSHKeepAlivePaddingMaxBytes"
+	SSHKeepAlivePeriodMin                          = "SSHKeepAlivePeriodMin"
+	SSHKeepAlivePeriodMax                          = "SSHKeepAlivePeriodMax"
+	SSHKeepAlivePeriodicTimeout                    = "SSHKeepAlivePeriodicTimeout"
+	SSHKeepAlivePeriodicInactivePeriod             = "SSHKeepAlivePeriodicInactivePeriod"
+	SSHKeepAliveProbeTimeout                       = "SSHKeepAliveProbeTimeout"
+	SSHKeepAliveProbeInactivePeriod                = "SSHKeepAliveProbeInactivePeriod"
+	HTTPProxyOriginServerTimeout                   = "HTTPProxyOriginServerTimeout"
+	HTTPProxyMaxIdleConnectionsPerHost             = "HTTPProxyMaxIdleConnectionsPerHost"
+	FetchRemoteServerListTimeout                   = "FetchRemoteServerListTimeout"
+	FetchRemoteServerListRetryPeriod               = "FetchRemoteServerListRetryPeriod"
+	FetchRemoteServerListStalePeriod               = "FetchRemoteServerListStalePeriod"
+	RemoteServerListSignaturePublicKey             = "RemoteServerListSignaturePublicKey"
+	RemoteServerListURLs                           = "RemoteServerListURLs"
+	ObfuscatedServerListRootURLs                   = "ObfuscatedServerListRootURLs"
+	PsiphonAPIRequestTimeout                       = "PsiphonAPIRequestTimeout"
+	PsiphonAPIStatusRequestPeriodMin               = "PsiphonAPIStatusRequestPeriodMin"
+	PsiphonAPIStatusRequestPeriodMax               = "PsiphonAPIStatusRequestPeriodMax"
+	PsiphonAPIStatusRequestShortPeriodMin          = "PsiphonAPIStatusRequestShortPeriodMin"
+	PsiphonAPIStatusRequestShortPeriodMax          = "PsiphonAPIStatusRequestShortPeriodMax"
+	PsiphonAPIStatusRequestPaddingMinBytes         = "PsiphonAPIStatusRequestPaddingMinBytes"
+	PsiphonAPIStatusRequestPaddingMaxBytes         = "PsiphonAPIStatusRequestPaddingMaxBytes"
+	PsiphonAPIPersistentStatsMaxCount              = "PsiphonAPIPersistentStatsMaxCount"
+	PsiphonAPIConnectedRequestPeriod               = "PsiphonAPIConnectedRequestPeriod"
+	PsiphonAPIConnectedRequestRetryPeriod          = "PsiphonAPIConnectedRequestRetryPeriod"
+	PsiphonAPIClientVerificationRequestRetryPeriod = "PsiphonAPIClientVerificationRequestRetryPeriod"
+	PsiphonAPIClientVerificationRequestMaxRetries  = "PsiphonAPIClientVerificationRequestMaxRetries"
+	FetchSplitTunnelRoutesTimeout                  = "FetchSplitTunnelRoutesTimeout"
+	SplitTunnelRoutesURLFormat                     = "SplitTunnelRoutesURLFormat"
+	SplitTunnelRoutesSignaturePublicKey            = "SplitTunnelRoutesSignaturePublicKey"
+	SplitTunnelDNSServer                           = "SplitTunnelDNSServer"
+	FetchUpgradeTimeout                            = "FetchUpgradeTimeout"
+	FetchUpgradeRetryPeriod                        = "FetchUpgradeRetryPeriod"
+	FetchUpgradeStalePeriod                        = "FetchUpgradeStalePeriod"
+	UpgradeDownloadURLs                            = "UpgradeDownloadURLs"
+	UpgradeDownloadClientVersionHeader             = "UpgradeDownloadClientVersionHeader"
+	ImpairedProtocolClassificationDuration         = "ImpairedProtocolClassificationDuration"
+	ImpairedProtocolClassificationThreshold        = "ImpairedProtocolClassificationThreshold"
+	TotalBytesTransferredNoticePeriod              = "TotalBytesTransferredNoticePeriod"
+	MeekDialDomainsOnly                            = "MeekDialDomainsOnly"
+	MeekLimitBufferSizes                           = "MeekLimitBufferSizes"
+	MeekCookieMaxPadding                           = "MeekCookieMaxPadding"
+	MeekFullReceiveBufferLength                    = "MeekFullReceiveBufferLength"
+	MeekReadPayloadChunkLength                     = "MeekReadPayloadChunkLength"
+	MeekLimitedFullReceiveBufferLength             = "MeekLimitedFullReceiveBufferLength"
+	MeekLimitedReadPayloadChunkLength              = "MeekLimitedReadPayloadChunkLength"
+	MeekMinPollInterval                            = "MeekMinPollInterval"
+	MeekMinPollIntervalJitter                      = "MeekMinPollIntervalJitter"
+	MeekMaxPollInterval                            = "MeekMaxPollInterval"
+	MeekMaxPollIntervalJitter                      = "MeekMaxPollIntervalJitter"
+	MeekPollIntervalMultiplier                     = "MeekPollIntervalMultiplier"
+	MeekPollIntervalJitter                         = "MeekPollIntervalJitter"
+	MeekApplyPollIntervalMultiplierProbability     = "MeekApplyPollIntervalMultiplierProbability"
+	MeekRoundTripRetryDeadline                     = "MeekRoundTripRetryDeadline"
+	MeekRoundTripRetryMinDelay                     = "MeekRoundTripRetryMinDelay"
+	MeekRoundTripRetryMaxDelay                     = "MeekRoundTripRetryMaxDelay"
+	MeekRoundTripRetryMultiplier                   = "MeekRoundTripRetryMultiplier"
+	MeekRoundTripTimeout                           = "MeekRoundTripTimeout"
+	SelectAndroidTLSProbability                    = "SelectAndroidTLSProbability"
+	TransformHostNameProbability                   = "TransformHostNameProbability"
+	PickUserAgentProbability                       = "PickUserAgentProbability"
+)
+
+const (
+	useNetworkLatencyMultiplier = 1
+)
+
+// defaultClientParameters specifies the type, default value, and minimum
+// value for all dynamically configurable client parameters.
+//
+// Do not change the names or types of existing values, as that can break
+// client logic or cause parameters to not be applied.
+//
+// Minimum values are a fail-safe for cases where lower values would break the
+// client logic. For example, setting a ConnectionWorkerPoolSize of 0 would
+// make the client never connect.
+var defaultClientParameters = map[string]struct {
+	value   interface{}
+	minimum interface{}
+	flags   int32
+}{
+	// NetworkLatencyMultiplier defaults to 0, meaning off. But when set, it
+	// must be a multiplier >= 1.
+
+	NetworkLatencyMultiplier: {value: 0.0, minimum: 1.0},
+
+	TacticsWaitPeriod:        {value: 10 * time.Second, minimum: 0 * time.Second, flags: useNetworkLatencyMultiplier},
+	TacticsRetryPeriod:       {value: 5 * time.Second, minimum: 1 * time.Millisecond},
+	TacticsRetryPeriodJitter: {value: 0.3, minimum: 0.0},
+	TacticsTimeout:           {value: 2 * time.Minute, minimum: 1 * time.Second, flags: useNetworkLatencyMultiplier},
+
+	ConnectionWorkerPoolSize:                 {value: 10, minimum: 1},
+	TunnelConnectTimeout:                     {value: 20 * time.Second, minimum: 1 * time.Second, flags: useNetworkLatencyMultiplier},
+	EstablishTunnelTimeout:                   {value: 300 * time.Second, minimum: time.Duration(0)},
+	EstablishTunnelWorkTime:                  {value: 60 * time.Second, minimum: 1 * time.Second},
+	EstablishTunnelPausePeriod:               {value: 5 * time.Second, minimum: 1 * time.Millisecond},
+	EstablishTunnelPausePeriodJitter:         {value: 0.1, minimum: 0.0},
+	EstablishTunnelServerAffinityGracePeriod: {value: 1 * time.Second, minimum: time.Duration(0), flags: useNetworkLatencyMultiplier},
+	StaggerConnectionWorkersPeriod:           {value: time.Duration(0), minimum: time.Duration(0)},
+	StaggerConnectionWorkersJitter:           {value: 0.1, minimum: 0.0},
+	LimitMeekConnectionWorkers:               {value: 0, minimum: 0},
+	IgnoreHandshakeStatsRegexps:              {value: false},
+	TunnelOperateShutdownTimeout:             {value: 1 * time.Second, minimum: 1 * time.Millisecond, flags: useNetworkLatencyMultiplier},
+	TunnelPortForwardDialTimeout:             {value: 10 * time.Second, minimum: 1 * time.Millisecond, flags: useNetworkLatencyMultiplier},
+	TunnelRateLimits:                         {value: common.RateLimits{}},
+
+	// PrioritizeTunnelProtocolsCandidateCount should be set to at least
+	// ConnectionWorkerPoolSize in order to use only priotitized protocols in
+	// the first establishment round. Even then, this will only happen if the
+	// client has sufficient candidates supporting the prioritized protocols.
+
+	PrioritizeTunnelProtocols:               {value: protocol.TunnelProtocols{}},
+	PrioritizeTunnelProtocolsCandidateCount: {value: 10, minimum: 0},
+	LimitTunnelProtocols:                    {value: protocol.TunnelProtocols{}},
+
+	AdditionalCustomHeaders: {value: make(http.Header)},
+
+	// Speed test and SSH keep alive padding is intended to frustrate
+	// fingerprinting and should not exceed ~1 IP packet size.
+	//
+	// Currently, each serialized speed test sample, populated with real
+	// values, is approximately 100 bytes. All SpeedTestMaxSampleCount samples
+	// are loaded into memory are sent as API inputs.
+
+	SpeedTestPaddingMinBytes: {value: 0, minimum: 0},
+	SpeedTestPaddingMaxBytes: {value: 256, minimum: 0},
+	SpeedTestMaxSampleCount:  {value: 25, minimum: 1},
+
+	// The Psiphon server times out inactive tunnels after 5 minutes, so this
+	// is a soft max for SSHKeepAlivePeriodMax.
+
+	SSHKeepAliveSpeedTestSampleProbability: {value: 0.5, minimum: 0.0},
+	SSHKeepAlivePaddingMinBytes:            {value: 0, minimum: 0},
+	SSHKeepAlivePaddingMaxBytes:            {value: 256, minimum: 0},
+	SSHKeepAlivePeriodMin:                  {value: 1 * time.Minute, minimum: 1 * time.Second},
+	SSHKeepAlivePeriodMax:                  {value: 2 * time.Minute, minimum: 1 * time.Second},
+	SSHKeepAlivePeriodicTimeout:            {value: 30 * time.Second, minimum: 1 * time.Second, flags: useNetworkLatencyMultiplier},
+	SSHKeepAlivePeriodicInactivePeriod:     {value: 10 * time.Second, minimum: 1 * time.Second},
+	SSHKeepAliveProbeTimeout:               {value: 30 * time.Second, minimum: 1 * time.Second, flags: useNetworkLatencyMultiplier},
+	SSHKeepAliveProbeInactivePeriod:        {value: 10 * time.Second, minimum: 1 * time.Second},
+
+	HTTPProxyOriginServerTimeout:       {value: 15 * time.Second, minimum: time.Duration(0), flags: useNetworkLatencyMultiplier},
+	HTTPProxyMaxIdleConnectionsPerHost: {value: 50, minimum: 0},
+
+	FetchRemoteServerListTimeout:       {value: 30 * time.Second, minimum: 1 * time.Second, flags: useNetworkLatencyMultiplier},
+	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{}},
+
+	PsiphonAPIRequestTimeout: {value: 20 * time.Second, minimum: 1 * time.Second, flags: useNetworkLatencyMultiplier},
+
+	PsiphonAPIStatusRequestPeriodMin:       {value: 5 * time.Minute, minimum: 1 * time.Second},
+	PsiphonAPIStatusRequestPeriodMax:       {value: 10 * time.Minute, minimum: 1 * time.Second},
+	PsiphonAPIStatusRequestShortPeriodMin:  {value: 5 * time.Second, minimum: 1 * time.Second},
+	PsiphonAPIStatusRequestShortPeriodMax:  {value: 10 * time.Second, minimum: 1 * time.Second},
+	PsiphonAPIStatusRequestPaddingMinBytes: {value: 0, minimum: 0},
+	PsiphonAPIStatusRequestPaddingMaxBytes: {value: 256, minimum: 0},
+	PsiphonAPIPersistentStatsMaxCount:      {value: 100, minimum: 1},
+
+	PsiphonAPIConnectedRequestRetryPeriod: {value: 5 * time.Second, minimum: 1 * time.Millisecond},
+
+	PsiphonAPIClientVerificationRequestRetryPeriod: {value: 5 * time.Second, minimum: 1 * time.Millisecond},
+	PsiphonAPIClientVerificationRequestMaxRetries:  {value: 10, minimum: 0},
+
+	FetchSplitTunnelRoutesTimeout:       {value: 60 * time.Second, minimum: 1 * time.Second, flags: useNetworkLatencyMultiplier},
+	SplitTunnelRoutesURLFormat:          {value: ""},
+	SplitTunnelRoutesSignaturePublicKey: {value: ""},
+	SplitTunnelDNSServer:                {value: ""},
+
+	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{}},
+	UpgradeDownloadClientVersionHeader: {value: ""},
+
+	ImpairedProtocolClassificationDuration:  {value: 2 * time.Minute, minimum: 1 * time.Millisecond, flags: useNetworkLatencyMultiplier},
+	ImpairedProtocolClassificationThreshold: {value: 3, minimum: 1},
+
+	TotalBytesTransferredNoticePeriod: {value: 5 * time.Minute, minimum: 1 * time.Second},
+
+	// The meek server times out inactive sessions after 45 seconds, so this
+	// is a soft max for MeekMaxPollInterval,  MeekRoundTripTimeout, and
+	// MeekRoundTripRetryDeadline. MeekCookieMaxPadding cannot exceed
+	// common.OBFUSCATE_SEED_LENGTH.
+
+	MeekDialDomainsOnly:                        {value: false},
+	MeekLimitBufferSizes:                       {value: false},
+	MeekCookieMaxPadding:                       {value: 256, minimum: 0},
+	MeekFullReceiveBufferLength:                {value: 4194304, minimum: 1024},
+	MeekReadPayloadChunkLength:                 {value: 65536, minimum: 1024},
+	MeekLimitedFullReceiveBufferLength:         {value: 131072, minimum: 1024},
+	MeekLimitedReadPayloadChunkLength:          {value: 4096, minimum: 1024},
+	MeekMinPollInterval:                        {value: 100 * time.Millisecond, minimum: 1 * time.Millisecond},
+	MeekMinPollIntervalJitter:                  {value: 0.3, minimum: 0.0},
+	MeekMaxPollInterval:                        {value: 5 * time.Second, minimum: 1 * time.Millisecond},
+	MeekMaxPollIntervalJitter:                  {value: 0.1, minimum: 0.0},
+	MeekPollIntervalMultiplier:                 {value: 1.5, minimum: 0.0},
+	MeekPollIntervalJitter:                     {value: 0.1, minimum: 0.0},
+	MeekApplyPollIntervalMultiplierProbability: {value: 0.5},
+	MeekRoundTripRetryDeadline:                 {value: 5 * time.Second, minimum: 1 * time.Millisecond, flags: useNetworkLatencyMultiplier},
+	MeekRoundTripRetryMinDelay:                 {value: 50 * time.Millisecond, minimum: time.Duration(0)},
+	MeekRoundTripRetryMaxDelay:                 {value: 1 * time.Second, minimum: time.Duration(0)},
+	MeekRoundTripRetryMultiplier:               {value: 2.0, minimum: 0.0},
+	MeekRoundTripTimeout:                       {value: 20 * time.Second, minimum: 1 * time.Second, flags: useNetworkLatencyMultiplier},
+
+	SelectAndroidTLSProbability:  {value: 0.5},
+	TransformHostNameProbability: {value: 0.5},
+	PickUserAgentProbability:     {value: 0.5},
+}
+
+// ClientParameters is a set of client parameters. To use the parameters, call
+// Get. To apply new values to the parameters, call Set.
+type ClientParameters struct {
+	getValueLogger func(error)
+	snapshot       atomic.Value
+}
+
+// ClientParametersSnapshot is an atomic snapshot of the client parameter
+// values. ClientParameters.Get will return a snapshot which may be used to
+// read multiple related values atomically and consistently while the current
+// snapshot in ClientParameters may change concurrently.
+type ClientParametersSnapshot struct {
+	getValueLogger func(error)
+	tag            string
+	parameters     map[string]interface{}
+}
+
+// NewClientParameters initializes a new ClientParameters with the default
+// parameter values.
+//
+// getValueLogger is optional, and is used to report runtime errors with
+// getValue; see comment in getValue.
+func NewClientParameters(
+	getValueLogger func(error)) (*ClientParameters, error) {
+
+	clientParameters := &ClientParameters{
+		getValueLogger: getValueLogger,
+	}
+
+	_, err := clientParameters.Set("", false)
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	return clientParameters, nil
+}
+
+func makeDefaultParameters() (map[string]interface{}, error) {
+
+	parameters := make(map[string]interface{})
+
+	for name, defaults := range defaultClientParameters {
+
+		if defaults.value == nil {
+			return nil, common.ContextError(fmt.Errorf("default parameter missing value: %s", name))
+		}
+
+		if defaults.minimum != nil &&
+			reflect.TypeOf(defaults.value) != reflect.TypeOf(defaults.minimum) {
+
+			return nil, common.ContextError(fmt.Errorf("default parameter value and minimum type mismatch: %s", name))
+		}
+
+		_, isDuration := defaults.value.(time.Duration)
+		if defaults.flags&useNetworkLatencyMultiplier != 0 && !isDuration {
+			return nil, common.ContextError(fmt.Errorf("default non-duration parameter uses multipler: %s", name))
+		}
+
+		parameters[name] = defaults.value
+	}
+
+	return parameters, nil
+}
+
+// Set replaces the current parameters. First, a set of parameters are
+// initialized using the default values. Then, each applyParameters is applied
+// in turn, with the later instances having precedence.
+//
+// When skipOnError is true, unknown or invalid parameters in any
+// applyParameters are skipped instead of aborting with an error.
+//
+// When an error is returned, the previous parameters remain completely
+// unmodified.
+//
+// For use in logging, Set returns a count of the number of parameters applied
+// from each applyParameters.
+func (p *ClientParameters) Set(
+	tag string, skipOnError bool, applyParameters ...map[string]interface{}) ([]int, error) {
+
+	var counts []int
+
+	parameters, err := makeDefaultParameters()
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	for i := 0; i < len(applyParameters); i++ {
+
+		count := 0
+
+		for name, value := range applyParameters[i] {
+
+			existingValue, ok := parameters[name]
+			if !ok {
+				if skipOnError {
+					continue
+				}
+				return nil, common.ContextError(fmt.Errorf("unknown parameter: %s", name))
+			}
+
+			// Accept strings such as "1h" for duration parameters.
+
+			switch existingValue.(type) {
+			case time.Duration:
+				if s, ok := value.(string); ok {
+					if d, err := time.ParseDuration(s); err == nil {
+						value = d
+					}
+				}
+			}
+
+			// A JSON remarshal resolves cases where applyParameters is a
+			// result of unmarshal-into-interface, in which case non-scalar
+			// values will not have the expecte types; see:
+			// https://golang.org/pkg/encoding/json/#Unmarshal. This remarshal
+			// also results in a deep copy.
+
+			marshaledValue, err := json.Marshal(value)
+			if err != nil {
+				continue
+			}
+
+			newValuePtr := reflect.New(reflect.TypeOf(existingValue))
+
+			err = json.Unmarshal(marshaledValue, newValuePtr.Interface())
+			if err != nil {
+				if skipOnError {
+					continue
+				}
+				return nil, common.ContextError(fmt.Errorf("unmarshal parameter %s failed: %s", name, err))
+			}
+
+			newValue := newValuePtr.Elem().Interface()
+
+			// Perform type-specific validation for some cases.
+
+			// TODO: require RemoteServerListSignaturePublicKey when
+			// RemoteServerListURLs is set?
+
+			switch v := newValue.(type) {
+			case DownloadURLs:
+				err := v.DecodeAndValidate()
+				if err != nil {
+					if skipOnError {
+						continue
+					}
+					return nil, common.ContextError(err)
+				}
+			case protocol.TunnelProtocols:
+				err := v.Validate()
+				if err != nil {
+					if skipOnError {
+						continue
+					}
+					return nil, common.ContextError(err)
+				}
+			}
+
+			// Enforce any minimums. Assumes defaultClientParameters[name]
+			// exists.
+			if defaultClientParameters[name].minimum != nil {
+				valid := true
+				switch v := newValue.(type) {
+				case int:
+					m, ok := defaultClientParameters[name].minimum.(int)
+					if !ok || v < m {
+						valid = false
+					}
+				case float64:
+					m, ok := defaultClientParameters[name].minimum.(float64)
+					if !ok || v < m {
+						valid = false
+					}
+				case time.Duration:
+					m, ok := defaultClientParameters[name].minimum.(time.Duration)
+					if !ok || v < m {
+						valid = false
+					}
+				default:
+					if skipOnError {
+						continue
+					}
+					return nil, common.ContextError(fmt.Errorf("unexpected parameter with minimum: %s", name))
+				}
+				if !valid {
+					if skipOnError {
+						continue
+					}
+					return nil, common.ContextError(fmt.Errorf("parameter below minimum: %s", name))
+				}
+			}
+
+			parameters[name] = newValue
+
+			count++
+		}
+
+		counts = append(counts, count)
+	}
+
+	snapshot := &ClientParametersSnapshot{
+		getValueLogger: p.getValueLogger,
+		tag:            tag,
+		parameters:     parameters,
+	}
+
+	p.snapshot.Store(snapshot)
+
+	return counts, nil
+}
+
+// Get returns the current parameters. Values read from the current parameters
+// are not deep copies and must be treated read-only.
+func (p *ClientParameters) Get() *ClientParametersSnapshot {
+	return p.snapshot.Load().(*ClientParametersSnapshot)
+}
+
+// Tag returns the tag associated with these parameters.
+func (p *ClientParametersSnapshot) Tag() string {
+	return p.tag
+}
+
+// getValue sets target to the value of the named parameter.
+//
+// It is an error if the name is not found, target is not a pointer, or the
+// type of target points to does not match the value.
+//
+// Any of these conditions would be a bug in the caller. getValue does not
+// panic in these cases as the client is deployed as a library in various apps
+// and the failure of Psiphon may not be a failure for the app process.
+//
+// Instead, errors are logged to the getValueLogger and getValue leaves the
+// target unset, which will result in the caller getting and using a zero
+// value of the requested type.
+func (p *ClientParametersSnapshot) getValue(name string, target interface{}) {
+
+	value, ok := p.parameters[name]
+	if !ok {
+		if p.getValueLogger != nil {
+			p.getValueLogger(common.ContextError(fmt.Errorf(
+				"value %s not found", name)))
+		}
+		return
+	}
+
+	valueType := reflect.TypeOf(value)
+
+	if reflect.PtrTo(valueType) != reflect.TypeOf(target) {
+		if p.getValueLogger != nil {
+			p.getValueLogger(common.ContextError(fmt.Errorf(
+				"value %s has unexpected type %s", name, valueType.Name())))
+		}
+		return
+	}
+
+	// Note: there is no deep copy of parameter values; the returned value may
+	// share memory with the original and should not be modified.
+
+	targetValue := reflect.ValueOf(target)
+
+	if targetValue.Kind() != reflect.Ptr {
+		p.getValueLogger(common.ContextError(fmt.Errorf(
+			"target for value %s is not pointer", name)))
+		return
+	}
+
+	targetValue.Elem().Set(reflect.ValueOf(value))
+}
+
+// String returns a string parameter value.
+func (p *ClientParametersSnapshot) String(name string) string {
+	value := ""
+	p.getValue(name, &value)
+	return value
+}
+
+// Strings returns a []string parameter value.
+func (p *ClientParametersSnapshot) Strings(name string) []string {
+	value := []string{}
+	p.getValue(name, &value)
+	return value
+}
+
+// Int returns an int parameter value.
+func (p *ClientParametersSnapshot) Int(name string) int {
+	value := int(0)
+	p.getValue(name, &value)
+	return value
+}
+
+// Bool returns a bool parameter value.
+func (p *ClientParametersSnapshot) Bool(name string) bool {
+	value := false
+	p.getValue(name, &value)
+	return value
+}
+
+// Float returns a float64 parameter value.
+func (p *ClientParametersSnapshot) Float(name string) float64 {
+	value := float64(0.0)
+	p.getValue(name, &value)
+	return value
+}
+
+// WeightedCoinFlip returns the result of common.FlipWeightedCoin using the
+// specified float parameter as the probability input.
+func (p *ClientParametersSnapshot) WeightedCoinFlip(name string) bool {
+	var value float64
+	p.getValue(name, &value)
+	return common.FlipWeightedCoin(value)
+}
+
+// Duration returns a time.Duration parameter value. When the duration
+// parameter has the useNetworkLatencyMultiplier flag, the
+// NetworkLatencyMultiplier is applied to the returned value.
+func (p *ClientParametersSnapshot) Duration(name string) time.Duration {
+	value := time.Duration(0)
+	p.getValue(name, &value)
+
+	defaultParameter, ok := defaultClientParameters[name]
+	if value > 0 && ok && defaultParameter.flags&useNetworkLatencyMultiplier != 0 {
+
+		multiplier := float64(0.0)
+		p.getValue(NetworkLatencyMultiplier, &multiplier)
+		if multiplier > 0.0 {
+			value = time.Duration(float64(value) * multiplier)
+		}
+
+	}
+
+	return value
+}
+
+// TunnelProtocols returns a protocol.TunnelProtocols parameter value.
+func (p *ClientParametersSnapshot) TunnelProtocols(name string) protocol.TunnelProtocols {
+	value := protocol.TunnelProtocols{}
+	p.getValue(name, &value)
+	return value
+}
+
+// DownloadURLs returns a DownloadURLs parameter value.
+func (p *ClientParametersSnapshot) DownloadURLs(name string) DownloadURLs {
+	value := DownloadURLs{}
+	p.getValue(name, &value)
+	return value
+}
+
+// RateLimits returns a common.RateLimits parameter value.
+func (p *ClientParametersSnapshot) RateLimits(name string) common.RateLimits {
+	value := common.RateLimits{}
+	p.getValue(name, &value)
+	return value
+}
+
+// HTTPHeaders returns an http.Header parameter value.
+func (p *ClientParametersSnapshot) HTTPHeaders(name string) http.Header {
+	value := make(http.Header)
+	p.getValue(name, &value)
+	return value
+}

+ 197 - 0
psiphon/common/parameters/clientParameters_test.go

@@ -0,0 +1,197 @@
+/*
+ * Copyright (c) 2018, 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 parameters
+
+import (
+	"net/http"
+	"reflect"
+	"testing"
+	"time"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
+)
+
+func TestGetDefaultParameters(t *testing.T) {
+
+	p, err := NewClientParameters(nil)
+	if err != nil {
+		t.Fatalf("NewClientParameters failed: %s", err)
+	}
+
+	for name, defaults := range defaultClientParameters {
+		switch v := defaults.value.(type) {
+		case string:
+			g := p.Get().String(name)
+			if v != g {
+				t.Fatalf("GetString returned %+v expected %+v", v, g)
+			}
+		case int:
+			g := p.Get().Int(name)
+			if v != g {
+				t.Fatalf("GetInt returned %+v expected %+v", v, g)
+			}
+		case float64:
+			g := p.Get().Float(name)
+			if v != g {
+				t.Fatalf("GetFloat returned %+v expected %+v", v, g)
+			}
+		case bool:
+			g := p.Get().Bool(name)
+			if v != g {
+				t.Fatalf("GetBool returned %+v expected %+v", v, g)
+			}
+		case time.Duration:
+			g := p.Get().Duration(name)
+			if v != g {
+				t.Fatalf("GetDuration returned %+v expected %+v", v, g)
+			}
+		case protocol.TunnelProtocols:
+			g := p.Get().TunnelProtocols(name)
+			if !reflect.DeepEqual(v, g) {
+				t.Fatalf("GetTunnelProtocols returned %+v expected %+v", v, g)
+			}
+		case DownloadURLs:
+			g := p.Get().DownloadURLs(name)
+			if !reflect.DeepEqual(v, g) {
+				t.Fatalf("GetDownloadURLs returned %+v expected %+v", v, g)
+			}
+		case common.RateLimits:
+			g := p.Get().RateLimits(name)
+			if !reflect.DeepEqual(v, g) {
+				t.Fatalf("GetRateLimits returned %+v expected %+v", v, g)
+			}
+		case http.Header:
+			g := p.Get().HTTPHeaders(name)
+			if !reflect.DeepEqual(v, g) {
+				t.Fatalf("GetHTTPHeaders returned %+v expected %+v", v, g)
+			}
+		default:
+			t.Fatalf("Unhandled default type: %s", name)
+		}
+	}
+}
+
+func TestGetValueLogger(t *testing.T) {
+
+	loggerCalled := false
+
+	p, err := NewClientParameters(
+		func(error) {
+			loggerCalled = true
+		})
+	if err != nil {
+		t.Fatalf("NewClientParameters failed: %s", err)
+	}
+
+	p.Get().Int("unknown-parameter-name")
+
+	if !loggerCalled {
+		t.Fatalf("logged not called")
+	}
+}
+
+func TestOverrides(t *testing.T) {
+
+	tag := "tag"
+	applyParameters := make(map[string]interface{})
+
+	// Below minimum, should not apply
+	defaultConnectionWorkerPoolSize := defaultClientParameters[ConnectionWorkerPoolSize].value.(int)
+	minimumConnectionWorkerPoolSize := defaultClientParameters[ConnectionWorkerPoolSize].minimum.(int)
+	newConnectionWorkerPoolSize := minimumConnectionWorkerPoolSize - 1
+	applyParameters[ConnectionWorkerPoolSize] = newConnectionWorkerPoolSize
+
+	// Above minimum, should apply
+	defaultPrioritizeTunnelProtocolsCandidateCount := defaultClientParameters[PrioritizeTunnelProtocolsCandidateCount].value.(int)
+	minimumPrioritizeTunnelProtocolsCandidateCount := defaultClientParameters[PrioritizeTunnelProtocolsCandidateCount].minimum.(int)
+	newPrioritizeTunnelProtocolsCandidateCount := minimumPrioritizeTunnelProtocolsCandidateCount + 1
+	applyParameters[PrioritizeTunnelProtocolsCandidateCount] = newPrioritizeTunnelProtocolsCandidateCount
+
+	p, err := NewClientParameters(nil)
+	if err != nil {
+		t.Fatalf("NewClientParameters failed: %s", err)
+	}
+
+	// No skip on error; should fail and not apply any changes
+
+	_, err = p.Set(tag, false, applyParameters)
+	if err == nil {
+		t.Fatalf("Set succeeded unexpectedly")
+	}
+
+	if p.Get().Tag() != "" {
+		t.Fatalf("GetTag returned unexpected value")
+	}
+
+	v := p.Get().Int(ConnectionWorkerPoolSize)
+	if v != defaultConnectionWorkerPoolSize {
+		t.Fatalf("GetInt returned unexpected ConnectionWorkerPoolSize: %d", v)
+	}
+
+	v = p.Get().Int(PrioritizeTunnelProtocolsCandidateCount)
+	if v != defaultPrioritizeTunnelProtocolsCandidateCount {
+		t.Fatalf("GetInt returned unexpected PrioritizeTunnelProtocolsCandidateCount: %d", v)
+	}
+
+	// Skip on error; should skip ConnectionWorkerPoolSize and apply PrioritizeTunnelProtocolsCandidateCount
+
+	counts, err := p.Set(tag, true, applyParameters)
+	if err != nil {
+		t.Fatalf("Set failed: %s", err)
+	}
+
+	if counts[0] != 1 {
+		t.Fatalf("Apply returned unexpected count: %d", counts[0])
+	}
+
+	v = p.Get().Int(ConnectionWorkerPoolSize)
+	if v != defaultConnectionWorkerPoolSize {
+		t.Fatalf("GetInt returned unexpected ConnectionWorkerPoolSize: %d", v)
+	}
+
+	v = p.Get().Int(PrioritizeTunnelProtocolsCandidateCount)
+	if v != newPrioritizeTunnelProtocolsCandidateCount {
+		t.Fatalf("GetInt returned unexpected PrioritizeTunnelProtocolsCandidateCount: %d", v)
+	}
+}
+
+func TestNetworkLatencyMultiplier(t *testing.T) {
+	p, err := NewClientParameters(nil)
+	if err != nil {
+		t.Fatalf("NewClientParameters failed: %s", err)
+	}
+
+	timeout1 := p.Get().Duration(TunnelConnectTimeout)
+
+	applyParameters := map[string]interface{}{"NetworkLatencyMultiplier": 2.0}
+
+	_, err = p.Set("", false, applyParameters)
+	if err != nil {
+		t.Fatalf("Set failed: %s", err)
+	}
+
+	timeout2 := p.Get().Duration(TunnelConnectTimeout)
+
+	if 2*timeout1 != timeout2 {
+		t.Fatalf("Unexpected timeouts: 2 * %s != %s", timeout1, timeout2)
+
+	}
+}

+ 122 - 0
psiphon/common/parameters/downloadURLs.go

@@ -0,0 +1,122 @@
+/*
+ * Copyright (c) 2018, 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 parameters
+
+import (
+	"encoding/base64"
+	"fmt"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+)
+
+// DownloadURL specifies a URL for downloading resources along with parameters
+// for the download strategy.
+type DownloadURL struct {
+
+	// URL is the location of the resource. This string is slightly obfuscated
+	// with base64 encoding to mitigate trivial binary executable string scanning.
+	URL string
+
+	// SkipVerify indicates whether to verify HTTPS certificates. It some
+	// circumvention scenarios, verification is not possible. This must
+	// only be set to true when the resource has its own verification mechanism.
+	SkipVerify bool
+
+	// OnlyAfterAttempts specifies how to schedule this URL when downloading
+	// 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.
+	OnlyAfterAttempts int
+}
+
+// DownloadURLs is a list of download URLs.
+type DownloadURLs []*DownloadURL
+
+// 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 {
+
+	hasOnlyAfterZero := false
+	for _, downloadURL := range d {
+		if downloadURL.OnlyAfterAttempts == 0 {
+			hasOnlyAfterZero = true
+		}
+		decodedURL, err := base64.StdEncoding.DecodeString(downloadURL.URL)
+		if err != nil {
+			return common.ContextError(fmt.Errorf("failed to decode URL: %s", err))
+		}
+
+		downloadURL.URL = string(decodedURL)
+	}
+
+	if !hasOnlyAfterZero {
+		return common.ContextError(fmt.Errorf("must be at least one DownloadURL with OnlyAfterAttempts = 0"))
+	}
+
+	return nil
+}
+
+// Select chooses a DownloadURL from the list.
+//
+// The first return value is the canonical URL, to be used
+// as a key when storing information related to the DownloadURLs,
+// such as an ETag.
+//
+// The second return value is the chosen download URL, which is
+// selected based at random from the candidates allowed in the
+// specified attempt.
+func (d DownloadURLs) 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
+			break
+		}
+	}
+
+	candidates := make([]int, 0)
+	for index, URL := range d {
+		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.
+		return "", "", true
+	}
+
+	selection, err := common.MakeSecureRandomInt(len(candidates))
+	if err != nil {
+		selection = 0
+	}
+	downloadURL := d[candidates[selection]]
+
+	return downloadURL.URL, canonicalURL, downloadURL.SkipVerify
+}

+ 188 - 0
psiphon/common/parameters/downloadURLs_test.go

@@ -0,0 +1,188 @@
+/*
+ * Copyright (c) 2018, 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 parameters
+
+import (
+	"encoding/base64"
+	"testing"
+)
+
+func TestDownloadURLs(t *testing.T) {
+
+	decodedA := "a.example.com"
+	encodedA := base64.StdEncoding.EncodeToString([]byte(decodedA))
+	encodedB := base64.StdEncoding.EncodeToString([]byte("b.example.com"))
+	encodedC := base64.StdEncoding.EncodeToString([]byte("c.example.com"))
+
+	testCases := []struct {
+		description                string
+		downloadURLs               DownloadURLs
+		attempts                   int
+		expectedValid              bool
+		expectedCanonicalURL       string
+		expectedDistinctSelections int
+	}{
+		{
+			"missing OnlyAfterAttempts = 0",
+			DownloadURLs{
+				{
+					URL:               encodedA,
+					OnlyAfterAttempts: 1,
+				},
+			},
+			1,
+			false,
+			decodedA,
+			0,
+		},
+		{
+			"single URL, multiple attempts",
+			DownloadURLs{
+				{
+					URL:               encodedA,
+					OnlyAfterAttempts: 0,
+				},
+			},
+			2,
+			true,
+			decodedA,
+			1,
+		},
+		{
+			"multiple URLs, single attempt",
+			DownloadURLs{
+				{
+					URL:               encodedA,
+					OnlyAfterAttempts: 0,
+				},
+				{
+					URL:               encodedB,
+					OnlyAfterAttempts: 1,
+				},
+				{
+					URL:               encodedC,
+					OnlyAfterAttempts: 1,
+				},
+			},
+			1,
+			true,
+			decodedA,
+			1,
+		},
+		{
+			"multiple URLs, multiple attempts",
+			DownloadURLs{
+				{
+					URL:               encodedA,
+					OnlyAfterAttempts: 0,
+				},
+				{
+					URL:               encodedB,
+					OnlyAfterAttempts: 1,
+				},
+				{
+					URL:               encodedC,
+					OnlyAfterAttempts: 1,
+				},
+			},
+			2,
+			true,
+			decodedA,
+			3,
+		},
+		{
+			"multiple URLs, multiple attempts",
+			DownloadURLs{
+				{
+					URL:               encodedA,
+					OnlyAfterAttempts: 0,
+				},
+				{
+					URL:               encodedB,
+					OnlyAfterAttempts: 1,
+				},
+				{
+					URL:               encodedC,
+					OnlyAfterAttempts: 3,
+				},
+			},
+			4,
+			true,
+			decodedA,
+			3,
+		},
+	}
+
+	for _, testCase := range testCases {
+		t.Run(testCase.description, func(t *testing.T) {
+
+			err := testCase.downloadURLs.DecodeAndValidate()
+
+			if testCase.expectedValid {
+				if err != nil {
+					t.Fatalf("unexpected validation error: %s", err)
+				}
+			} else {
+				if err == nil {
+					t.Fatalf("expected validation error")
+				}
+				return
+			}
+
+			// Track distinct selections for each attempt; the
+			// expected number of distinct should be for at least
+			// one particular attempt.
+			attemptDistinctSelections := make(map[int]map[string]int)
+			for i := 0; i < testCase.attempts; i++ {
+				attemptDistinctSelections[i] = make(map[string]int)
+			}
+
+			// Perform enough runs to account for random selection.
+			runs := 1000
+
+			attempt := 0
+			for i := 0; i < runs; i++ {
+				url, canonicalURL, skipVerify := testCase.downloadURLs.Select(attempt)
+				if canonicalURL != testCase.expectedCanonicalURL {
+					t.Fatalf("unexpected canonical URL: %s", canonicalURL)
+				}
+				if skipVerify {
+					t.Fatalf("expected skipVerify")
+				}
+				attemptDistinctSelections[attempt][url] += 1
+				attempt = (attempt + 1) % testCase.attempts
+			}
+
+			maxDistinctSelections := 0
+			for _, m := range attemptDistinctSelections {
+				if len(m) > maxDistinctSelections {
+					maxDistinctSelections = len(m)
+				}
+			}
+
+			if maxDistinctSelections != testCase.expectedDistinctSelections {
+				t.Fatalf("got %d distinct selections, expected %d",
+					maxDistinctSelections,
+					testCase.expectedDistinctSelections)
+			}
+		})
+	}
+
+}

+ 19 - 5
psiphon/common/protocol/protocol.go

@@ -20,6 +20,9 @@
 package protocol
 
 import (
+	"encoding/json"
+	"fmt"
+
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/osl"
 )
@@ -60,7 +63,18 @@ const (
 	PSIPHON_API_HANDSHAKE_AUTHORIZATIONS = "authorizations"
 )
 
-var SupportedTunnelProtocols = []string{
+type TunnelProtocols []string
+
+func (t TunnelProtocols) Validate() error {
+	for _, p := range t {
+		if !common.Contains(SupportedTunnelProtocols, p) {
+			return common.ContextError(fmt.Errorf("invalid tunnel protocol: %s", p))
+		}
+	}
+	return nil
+}
+
+var SupportedTunnelProtocols = TunnelProtocols{
 	TUNNEL_PROTOCOL_FRONTED_MEEK,
 	TUNNEL_PROTOCOL_FRONTED_MEEK_HTTP,
 	TUNNEL_PROTOCOL_UNFRONTED_MEEK,
@@ -70,7 +84,7 @@ var SupportedTunnelProtocols = []string{
 	TUNNEL_PROTOCOL_SSH,
 }
 
-var SupportedServerEntrySources = []string{
+var SupportedServerEntrySources = TunnelProtocols{
 	SERVER_ENTRY_SOURCE_EMBEDDED,
 	SERVER_ENTRY_SOURCE_REMOTE,
 	SERVER_ENTRY_SOURCE_DISCOVERY,
@@ -108,7 +122,7 @@ func TunnelProtocolUsesObfuscatedSessionTickets(protocol string) bool {
 
 func UseClientTunnelProtocol(
 	clientProtocol string,
-	serverProtocols []string) bool {
+	serverProtocols TunnelProtocols) bool {
 
 	// When the server is running _both_ fronted HTTP and
 	// fronted HTTPS, use the client's reported tunnel
@@ -136,6 +150,7 @@ type HandshakeResponse struct {
 	ClientRegion           string              `json:"client_region"`
 	ServerTimestamp        string              `json:"server_timestamp"`
 	ActiveAuthorizationIDs []string            `json:"active_authorization_ids"`
+	TacticsPayload         json.RawMessage     `json:"tactics_payload"`
 }
 
 type ConnectedResponse struct {
@@ -154,8 +169,7 @@ type SSHPasswordPayload struct {
 }
 
 type MeekCookieData struct {
-	ServerAddress        string `json:"p"`
-	SessionID            string `json:"s"`
 	MeekProtocolVersion  int    `json:"v"`
 	ClientTunnelProtocol string `json:"t"`
+	EndPoint             string `json:"e"`
 }

+ 47 - 18
psiphon/common/protocol/serverEntry.go

@@ -59,6 +59,8 @@ type ServerEntry struct {
 	MeekFrontingAddresses         []string `json:"meekFrontingAddresses"`
 	MeekFrontingAddressesRegex    string   `json:"meekFrontingAddressesRegex"`
 	MeekFrontingDisableSNI        bool     `json:"meekFrontingDisableSNI"`
+	TacticsRequestPublicKey       string   `json:"tacticsRequestPublicKey"`
+	TacticsRequestObfuscatedKey   string   `json:"tacticsRequestObfuscatedKey"`
 
 	// These local fields are not expected to be present in downloaded server
 	// entries. They are added by the client to record and report stats about
@@ -68,11 +70,17 @@ type ServerEntry struct {
 }
 
 // GetCapability returns the server capability corresponding
-// to the protocol.
+// to the tunnel protocol.
 func GetCapability(protocol string) string {
 	return strings.TrimSuffix(protocol, "-OSSH")
 }
 
+// GetTacticsCapability returns the server tactics capability
+// corresponding to the tunnel protocol.
+func GetTacticsCapability(protocol string) string {
+	return GetCapability(protocol) + "-TACTICS"
+}
+
 // SupportsProtocol returns true if and only if the ServerEntry has
 // the necessary capability to support the specified tunnel protocol.
 func (serverEntry *ServerEntry) SupportsProtocol(protocol string) bool {
@@ -82,38 +90,59 @@ func (serverEntry *ServerEntry) SupportsProtocol(protocol string) bool {
 
 // GetSupportedProtocols returns a list of tunnel protocols supported
 // by the ServerEntry's capabilities.
-func (serverEntry *ServerEntry) GetSupportedProtocols(excludeMeek bool) []string {
+func (serverEntry *ServerEntry) GetSupportedProtocols(
+	limitTunnelProtocols []string,
+	impairedTunnelProtocols []string,
+	excludeMeek bool) []string {
+
 	supportedProtocols := make([]string, 0)
+
 	for _, protocol := range SupportedTunnelProtocols {
+
+		if len(limitTunnelProtocols) > 0 &&
+			!common.Contains(limitTunnelProtocols, protocol) {
+			continue
+		}
+
+		if len(impairedTunnelProtocols) > 0 &&
+			!common.Contains(impairedTunnelProtocols, protocol) {
+			continue
+		}
+
 		if excludeMeek && TunnelProtocolUsesMeek(protocol) {
 			continue
 		}
+
 		if serverEntry.SupportsProtocol(protocol) {
 			supportedProtocols = append(supportedProtocols, protocol)
 		}
+
 	}
 	return supportedProtocols
 }
 
-// DisableImpairedProtocols modifies the ServerEntry to disable
-// the specified protocols.
-// Note: this assumes that protocol capabilities are 1-to-1.
-func (serverEntry *ServerEntry) DisableImpairedProtocols(impairedProtocols []string) {
-	capabilities := make([]string, 0)
-	for _, capability := range serverEntry.Capabilities {
-		omit := false
-		for _, protocol := range impairedProtocols {
-			requiredCapability := GetCapability(protocol)
-			if capability == requiredCapability {
-				omit = true
-				break
-			}
+// GetSupportedTacticsProtocols returns a list of tunnel protocols,
+// supported by the ServerEntry's capabilities, that may be used
+// for tactics requests.
+func (serverEntry *ServerEntry) GetSupportedTacticsProtocols() []string {
+
+	supportedProtocols := make([]string, 0)
+
+	for _, protocol := range SupportedTunnelProtocols {
+
+		if !TunnelProtocolUsesMeek(protocol) {
+			continue
 		}
-		if !omit {
-			capabilities = append(capabilities, capability)
+
+		requiredCapability := GetTacticsCapability(protocol)
+		if !common.Contains(serverEntry.Capabilities, requiredCapability) {
+			continue
 		}
+
+		supportedProtocols = append(supportedProtocols, protocol)
 	}
-	serverEntry.Capabilities = capabilities
+
+	return supportedProtocols
 }
 
 // SupportsSSHAPIRequests returns true when the server supports

+ 1478 - 0
psiphon/common/tactics/tactics.go

@@ -0,0 +1,1478 @@
+/*
+ * Copyright (c) 2018, 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 tactics provides dynamic Psiphon client configuration based on GeoIP
+attributes, API parameters, and speed test data. The tactics implementation
+works in concert with the "parameters" package, allowing contextual
+optimization of Psiphon client parameters; for example, customizing
+NetworkLatencyMultiplier to adjust timeouts for clients on slow networks; or
+customizeing LimitTunnelProtocols and ConnectionWorkerPoolSize to circumvent
+specific blocking conditions.
+
+Clients obtain tactics from a Psiphon server. Tactics are configured with a hot-
+reloadable, JSON format server config file. The config file specifies default
+tactics for all clients as well as a list of filtered tactics. For each filter,
+if the client's attributes satisfy the filter then additional tactics are merged
+into the tactics set provided to the client.
+
+Tactics configuration is optimized for a modest number of filters -- dozens --
+and very many GeoIP matches in each filter.
+
+A Psiphon client "tactics request" is an an untunneled, pre-establishment
+request to obtain tactics, which will in turn be applied and used in the normal
+tunnel establishment sequence; the tactics request may result in custom
+timeouts, protocol selection, and other tunnel establishment behavior.
+
+The client will delay its normal establishment sequence and launch a tactics
+request only when it has no stored, valid tactics for its current network
+context. The normal establishment sequence will begin, regardless of tactics
+request outcome, after TacticsWaitPeriod; this ensures that the client will not
+stall its establishment process when the tactics request cannot complete.
+
+Tactics are configured with a TTL, which is converted to an expiry time on the
+client when tactics are received and stored. When the client starts its
+establishment sequence and finds stored, unexpired tactics, no tactics request
+is made. The expiry time serves to prevent execess tactics requests and avoid a
+fingerprintable network sequence that would result from always performing the
+tactics request.
+
+The client calls UseStoredTactics to check for stored tactics; and if none is
+found (there is no record or it is expired) the client proceeds to call
+FetchTactics to make the tactics request.
+
+In the Psiphon client and server, the tactics request is transported using the
+meek protocol. In this case, meek is configured as a simple HTTP round trip
+transport and does not relay arbitrary streams of data and does not allocate
+resources required for relay mode. On the Psiphon server, the same meek
+component handles both tactics requests and tunnel relays. Anti-probing for
+tactics endpoints are thus provided as usual by meek. A meek request is routed
+based on an routing field in the obfuscated meek cookie.
+
+As meek may be plaintext and as TLS certificate verification is sometimes
+skipped, the tactics request payload is wrapped with NaCl box and further
+wrapped in a padded obfuscator. Distinct request and response nonces are used to
+mitigate replay attacks. Clients generate ephemeral NaCl key pairs and the
+server public key is obtained from the server entry. The server entry also
+contains capabilities indicating that a Psiphon server supports tactics requests
+and which meek protocol is to be used.
+
+The Psiphon client requests, stores, and applies distinct tactics based on its
+current network context. The client uses platform-specific APIs to obtain a fine
+grain network ID based on, for example BSSID for WiFi or MCC/MNC for mobile.
+These values provides accurate detection of network context changes and can be
+obtained from the client device without any network activity. As the network ID
+is personally identifying, this ID is only used by the client and is never sent
+to the Psiphon server. The client obtains the current network ID from a callback
+made from tunnel-core to native client code.
+
+Tactics returned to the Psiphon client are accompanied by a "tag" which is a
+hash digest of the merged tactics data. This tag uniquely identifies the
+tactics. The client reports the tactics it is employing through the
+"applied_tactics" common metrics API parameter. When fetching new tactics, the
+client reports the stored (and possibly expired) tactics it has through the
+"stored_tactics" API parameter. The stored tactics tag is used to avoid
+redownloading redundant tactics data; when the tactics response indicates the
+tag is unchanged, no tactics data is returned and the client simply extends the
+expiry of the data is already has.
+
+The Psiphon handshake API returns tactics in its response. This enabled regular
+tactics expiry extension without requiring any distinct tactics request or
+tactics data transfer when the tag is unchanged. Psiphon clients that connect
+regularly and successfully with make almost no untunnled tactics requests except
+for new network IDs. Returning tactics in the handshake reponse also provides
+tactics in the case where a client is unable to complete an untunneled tactics
+request but can otherwise establish a tunnel. Clients will abort any outstanding
+untunneled tactics requests or scheduled retries once a handshake has completed.
+
+The client handshake request component calls SetTacticsAPIParameters to populate
+the handshake request parameters with tactics inputs, and calls
+HandleTacticsPayload to process the tactics payload in the handshake response.
+
+The core tactics data is custom values for a subset of the parameters in
+parameters.ClientParameters. A client takes the default ClientParameters,
+applies any custom values set in its config file, and then applies any stored or
+received tactics. Each time the tactics changes, this process is repeated so
+that obsolete tactics parameters are not retained in the client's
+ClientParameters instance.
+
+Tactics has a probability parameter that is used in a weighted coin flip to
+determine if the tactics is to be applied or skipped for the current client
+session. This allows for experimenting with provisional tactics; and obtaining
+non-tactic sample metrics in situations which would otherwise always use a
+tactic.
+
+Speed test data is used in filtered tactics for selection of parameters such as
+timeouts.
+
+A speed test sample records the RTT of an application-level round trip to a
+Psiphon server -- either a meek HTTP round trip or an SSH request round trip.
+The round trip should be preformed after an TCP, TLS, SSH, etc. handshake so
+that the RTT includes only the application-level round trip. Each sample also
+records the tunnel/meek protocol used, the Psiphon server region, and a
+timestamp; these values may be used to filter out outliers or stale samples. The
+samples record bytes up/down, although at this time the speed test is focused on
+latency and the payload is simply anti-fingerprint padding and should not be
+larger than an IP packet.
+
+The Psiphon client records the latest SpeedTestMaxSampleCount speed test samples
+for each network context. SpeedTestMaxSampleCount should be  a modest size, as
+each speed test sample is ~100 bytes when serialzied and all samples (for one
+network ID) are loaded into memory and  sent as API inputs to tactics and
+handshake requests.
+
+When a tactics request is initiated and there are no speed test samples for
+current network ID, the tactics request is proceeded by a speed test round trip,
+using the same meek round tripper, and that sample is stored and used for the
+tactics request. with a speed test The client records additional samples taken
+from regular SSH keep alive round trips and calls AddSpeedTestSample to store
+these.
+
+The client sends all its speed test samples, for the current network context, to
+the server in tactics and handshake requests; this allows the server logic to
+handle outliers and aggregation. Currently, filtered tactics support filerting
+on speed test RTT maximum, minimum, and median.
+*/
+package tactics
+
+import (
+	"bytes"
+	"context"
+	"crypto/md5"
+	"crypto/rand"
+	"encoding/base64"
+	"encoding/hex"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"sort"
+	"time"
+
+	"github.com/Psiphon-Inc/goarista/monotime"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/crypto/nacl/box"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters"
+)
+
+// TACTICS_PADDING_MAX_SIZE is used by the client as well as the server. This
+// value is not a dynamic client parameter since a tactics request is made
+// only when the client has no valid tactics, so no override of
+// TACTICS_PADDING_MAX_SIZE can be applied.
+
+const (
+	SPEED_TEST_END_POINT               = "speedtest"
+	TACTICS_END_POINT                  = "tactics"
+	MAX_REQUEST_BODY_SIZE              = 65536
+	SPEED_TEST_PADDING_MIN_SIZE        = 0
+	SPEED_TEST_PADDING_MAX_SIZE        = 256
+	TACTICS_PADDING_MAX_SIZE           = 256
+	SPEED_TEST_SAMPLES_PARAMETER_NAME  = "speed_test_samples"
+	APPLIED_TACTICS_TAG_PARAMETER_NAME = "applied_tactics_tag"
+	STORED_TACTICS_TAG_PARAMETER_NAME  = "stored_tactics_tag"
+	TACTICS_METRIC_EVENT_NAME          = "tactics"
+	NEW_TACTICS_TAG_LOG_FIELD_NAME     = "new_tactics_tag"
+	IS_TACTICS_REQUEST_LOG_FIELD_NAME  = "is_tactics_request"
+	AGGREGATION_MINIMUM                = "Minimum"
+	AGGREGATION_MAXIMUM                = "Maximum"
+	AGGREGATION_MEDIAN                 = "Median"
+)
+
+var (
+	TACTICS_REQUEST_NONCE  = []byte{1}
+	TACTICS_RESPONSE_NONCE = []byte{2}
+)
+
+// Server is a tactics server to be integrated with the Psiphon server meek and handshake
+// components.
+//
+// The meek server calls HandleEndPoint to handle untunneled tactics and speed test requests.
+// The handshake handler calls GetTacticsPayload to obtain a tactics payload to include with
+// the handsake response.
+//
+// The Server is a reloadable file; its exported fields are read from the tactics configuration
+// file.
+//
+// Each client will receive at least the DefaultTactics. Client GeoIP, API parameter, and speed
+// test sample attributes are matched against all filters and the tactics corresponding to any
+// matching filter are merged into the client tactics.
+//
+// The merge operation replaces any existing item in Parameter with a Parameter specified in
+// the newest matching tactics. The TTL and Probability of the newest matching tactics is taken,
+// although all but the DefaultTactics can omit the TTL and Probability fields.
+type Server struct {
+	common.ReloadableFile
+
+	// RequestPublicKey is the Server's tactics request NaCl box public key.
+	RequestPublicKey []byte
+
+	// RequestPublicKey is the Server's tactics request NaCl box private key.
+	RequestPrivateKey []byte
+
+	// RequestObfuscatedKey is the tactics request obfuscation key.
+	RequestObfuscatedKey []byte
+
+	// DefaultTactics is the baseline tactics for all clients. It must include a
+	// TTL and Probability.
+	DefaultTactics Tactics
+
+	// FilteredTactics is an ordered list of filter/tactics pairs. For a client,
+	// each fltered tactics is checked in order and merged into the clients
+	// tactics if the client's attributes satisfy the filter.
+	FilteredTactics []struct {
+		Filter  Filter
+		Tactics Tactics
+	}
+
+	logger                common.Logger
+	logFieldFormatter     common.APIParameterLogFieldFormatter
+	apiParameterValidator common.APIParameterValidator
+}
+
+// Filter defines a filter to match against client attributes.
+// Each field within the filter is optional and may be omitted.
+type Filter struct {
+
+	// Regions specifies a list of GeoIP regions/countries the client
+	// must match.
+	Regions []string
+	// Regions specifies a list of GeoIP ISPs the client must match.
+	ISPs []string
+
+	// APIParameters specifies API, e.g. handshake, parameter names and
+	// a list of values, one of which must be specified to match this
+	// filter. Only scalar string API parameters may be filtered.
+	APIParameters map[string][]string
+
+	// SpeedTestRTTMilliseconds specifies a Range filter field that the
+	// client speed test samples must satisfy.
+	SpeedTestRTTMilliseconds *Range
+
+	regionLookup map[string]bool
+	ispLookup    map[string]bool
+}
+
+// Range is a filter field which specifies that the aggregation of
+// the a client attribute is within specified upper and lower bounds.
+// At least one bound must be specified.
+//
+// For example, Range is to aggregate and filter client speed test
+// sample RTTs.
+type Range struct {
+
+	// Aggregation may be "Maximum", "Minimum", or "Median"
+	Aggregation string
+
+	// AtLeast specifies a lower bound for the aggregarted
+	// client value.
+	AtLeast *int
+
+	// AtMost specifies an upper bound for the aggregarted
+	// client value.
+	AtMost *int
+}
+
+// Payload is the data to be returned to the client in response to a
+// tactics request or in the handshake response.
+type Payload struct {
+
+	// Tag is the hash  tag of the accompanying Tactics. When the Tag
+	// is the same as the stored tag the client specified in its
+	// request, the Tactics will be empty as the client already has the
+	// correct data.
+	Tag string
+
+	// Tactics is a JSON-encoded Tactics struct and may be nil.
+	Tactics json.RawMessage
+}
+
+// Record is the tactics data persisted by the client. There is one
+// record for each network ID.
+type Record struct {
+
+	// The Tag is the hash of the tactics data and is used as the
+	// stored tag when making requests.
+	Tag string
+
+	// Expiry is the time when this perisisted tactics expires as
+	// determined by the client applying the TTL against its local
+	// clock when the tactics was stored.
+	Expiry time.Time
+
+	// Tactics is the core tactics data.
+	Tactics Tactics
+}
+
+// Tactics is the core tactics data. This is both what is set in
+// in the server configuration file and what is stored and used
+// by the cient.
+type Tactics struct {
+
+	// TTL is a string duration (e.g., "24h", the syntax supported
+	// by time.ParseDuration). This specifies how long the client
+	// should use the accompanying tactics until it expires.
+	//
+	// The client stores the TTL to use for extending the tactics
+	// expiry when a tactics request or handshake response returns
+	// no tactics data when the tag is unchanged.
+	TTL string
+
+	// Probability specifies the probability [0.0 - 1.0] with which
+	// the client should apply the tactics in a new session.
+	Probability float64
+
+	// Parameters specify client parameters to override. These must
+	// be a subset of parameter.ClientParameter values and follow
+	// the corresponding data type and minimum value constraints.
+	Parameters map[string]interface{}
+}
+
+// SpeedTestSample is speed test data for a single RTT event.
+type SpeedTestSample struct {
+
+	// Timestamp is the speed test event time, and may be used to discard
+	// stale samples. The server supplies the speed test timestamp. This
+	// value is truncated to the nearest hour as a privacy measure.
+	Timestamp time.Time `json:"t"`
+
+	// EndPointRegion is the region of the endpoint, the Psiphon server,
+	// used for the speed test. This may be used to exclude outlier samples
+	// using remote data centers.
+	EndPointRegion string `json:"r"`
+
+	// EndPointProtocol is the tactics or tunnel protocol use for the
+	// speed test round trip. The protocol may impact RTT.
+	EndPointProtocol string `json:"p"`
+
+	// All speed test samples should measure RTT as the time to complete
+	// an application-level round trip on top of a previously established
+	// tactics or tunnel prococol connection. The RTT should not include
+	// TCP, TLS, or SSH handshakes.
+	// This value is truncated to the nearest milliscond as a privacy
+	// measure.
+	RTTMilliseconds int `json:"rtt"`
+
+	// BytesUp is the size of the upstream payload in the round trip.
+	// Currently, the payload is limited to anti-fingerprint padding.
+	BytesUp int `json:"up"`
+
+	// BytesDown is the size of the downstream payload in the round trip.
+	// Currently, the payload is limited to anti-fingerprint padding.
+	BytesDown int `json:"dn"`
+}
+
+// NewServer creates Server using the specified tactics configuration file.
+//
+// The logger and logFieldFormatter callbacks are used to log errors and
+// metrics. The apiParameterValidator callback is used to validate client
+// API parameters submitted to the tactics request.
+func NewServer(
+	logger common.Logger,
+	logFieldFormatter common.APIParameterLogFieldFormatter,
+	apiParameterValidator common.APIParameterValidator,
+	configFilename string) (*Server, error) {
+
+	server := &Server{
+		logger:                logger,
+		logFieldFormatter:     logFieldFormatter,
+		apiParameterValidator: apiParameterValidator,
+	}
+
+	server.ReloadableFile = common.NewReloadableFile(
+		configFilename,
+		func(fileContent []byte) error {
+
+			var newServer Server
+			err := json.Unmarshal(fileContent, &newServer)
+			if err != nil {
+				return common.ContextError(err)
+			}
+
+			err = newServer.Validate()
+			if err != nil {
+				return common.ContextError(err)
+			}
+
+			newServer.initLookups()
+
+			// Modify actual traffic rules only after validation
+			server.RequestPublicKey = newServer.RequestPublicKey
+			server.RequestPrivateKey = newServer.RequestPrivateKey
+			server.RequestObfuscatedKey = newServer.RequestObfuscatedKey
+			server.DefaultTactics = newServer.DefaultTactics
+			server.FilteredTactics = newServer.FilteredTactics
+
+			return nil
+		})
+
+	_, err := server.Reload()
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	return server, nil
+}
+
+// Validate checks for correct tactics configuration values.
+func (server *Server) Validate() error {
+
+	if len(server.RequestPublicKey) != 32 ||
+		len(server.RequestPrivateKey) != 32 ||
+		len(server.RequestObfuscatedKey) != common.OBFUSCATE_KEY_LENGTH {
+		return common.ContextError(errors.New("invalid request key"))
+	}
+
+	validateTactics := func(tactics *Tactics, isDefault bool) error {
+
+		// Allow "" for 0, even though ParseDuration does not.
+		var d time.Duration
+		if tactics.TTL != "" {
+			var err error
+			d, err = time.ParseDuration(tactics.TTL)
+			if err != nil {
+				return common.ContextError(err)
+			}
+		}
+
+		if d <= 0 {
+			if isDefault {
+				return common.ContextError(errors.New("invalid duration"))
+			}
+			// For merging logic, Normalize any 0 duration to "".
+			tactics.TTL = ""
+		}
+
+		if (isDefault && tactics.Probability == 0.0) ||
+			tactics.Probability < 0.0 ||
+			tactics.Probability > 1.0 {
+
+			return common.ContextError(errors.New("invalid probability"))
+		}
+
+		clientParameters, err := parameters.NewClientParameters(nil)
+		if err != nil {
+			return common.ContextError(err)
+		}
+
+		_, err = clientParameters.Set("", false, tactics.Parameters)
+		if err != nil {
+			return common.ContextError(err)
+		}
+
+		return nil
+	}
+
+	validateRange := func(r *Range) error {
+		if r == nil {
+			return nil
+		}
+
+		if (r.AtLeast == nil && r.AtMost == nil) ||
+			((r.AtLeast != nil && r.AtMost != nil) && *r.AtLeast > *r.AtMost) {
+			return common.ContextError(errors.New("invalid range"))
+		}
+
+		switch r.Aggregation {
+		case AGGREGATION_MINIMUM, AGGREGATION_MAXIMUM, AGGREGATION_MEDIAN:
+		default:
+			return common.ContextError(errors.New("invalid aggregation"))
+		}
+
+		return nil
+	}
+
+	err := validateTactics(&server.DefaultTactics, true)
+	if err != nil {
+		return common.ContextError(fmt.Errorf("invalid default tactics: %s", err))
+	}
+
+	for i, filteredTactics := range server.FilteredTactics {
+
+		err := validateTactics(&filteredTactics.Tactics, false)
+
+		if err == nil {
+			err = validateRange(filteredTactics.Filter.SpeedTestRTTMilliseconds)
+		}
+
+		// TODO: validate Filter.APIParameters names are valid?
+
+		if err != nil {
+			return common.ContextError(fmt.Errorf("invalid filtered tactics %d: %s", i, err))
+		}
+	}
+
+	return nil
+}
+
+const lookupThreshold = 5
+
+// initLookups creates map lookups for filters where the number
+// of string values to compare against exceeds a threshold where
+// benchmarks show maps are faster than looping through a string
+// slice.
+func (server *Server) initLookups() {
+
+	for _, filteredTactics := range server.FilteredTactics {
+
+		if len(filteredTactics.Filter.Regions) >= lookupThreshold {
+			filteredTactics.Filter.regionLookup = make(map[string]bool)
+			for _, region := range filteredTactics.Filter.Regions {
+				filteredTactics.Filter.regionLookup[region] = true
+			}
+		}
+
+		if len(filteredTactics.Filter.ISPs) >= lookupThreshold {
+			filteredTactics.Filter.regionLookup = make(map[string]bool)
+			for _, ISP := range filteredTactics.Filter.ISPs {
+				filteredTactics.Filter.regionLookup[ISP] = true
+			}
+		}
+
+		// TODO: add lookups for APIParameters?
+		// Not expected to be long lists of values.
+	}
+}
+
+// GetTacticsPayload assembles and returns a tactics payload
+// for a client with the specified GeoIP, API parameter, and
+// speed test attributes.
+//
+// The speed test samples are expected to be in apiParams,
+// as is the stored tactics tag.
+//
+// GetTacticsPayload will always return a payload for any
+// client. When the client's stored tactics tag is identical
+// to the assembled tactics, the Payload.Tactics is nil.
+//
+// Elements of the returned Payload, e.g., tactics parameters,
+// will point to data in DefaultTactics and FilteredTactics
+// and must not be modifed.
+func (server *Server) GetTacticsPayload(
+	geoIPData common.GeoIPData,
+	apiParams common.APIParameters) (*Payload, error) {
+
+	server.ReloadableFile.RLock()
+	defer server.ReloadableFile.RUnlock()
+
+	tactics := server.DefaultTactics.clone()
+
+	var aggregatedValues map[string]int
+
+	for _, filteredTactics := range server.FilteredTactics {
+
+		if len(filteredTactics.Filter.Regions) > 0 {
+			if filteredTactics.Filter.regionLookup != nil {
+				if !filteredTactics.Filter.regionLookup[geoIPData.Country] {
+					continue
+				}
+			} else {
+				if !common.Contains(filteredTactics.Filter.Regions, geoIPData.Country) {
+					continue
+				}
+			}
+		}
+
+		if len(filteredTactics.Filter.ISPs) > 0 {
+			if filteredTactics.Filter.ispLookup != nil {
+				if !filteredTactics.Filter.ispLookup[geoIPData.ISP] {
+					continue
+				}
+			} else {
+				if !common.Contains(filteredTactics.Filter.ISPs, geoIPData.ISP) {
+					continue
+				}
+			}
+		}
+
+		if filteredTactics.Filter.APIParameters != nil {
+			mismatch := false
+			for name, values := range filteredTactics.Filter.APIParameters {
+				clientValue, err := getStringRequestParam(apiParams, name)
+				if err != nil || !common.Contains(values, clientValue) {
+					mismatch = true
+					break
+				}
+			}
+			if mismatch {
+				continue
+			}
+		}
+
+		if filteredTactics.Filter.SpeedTestRTTMilliseconds != nil {
+
+			var speedTestSamples []SpeedTestSample
+			err := getJSONRequestParam(apiParams, SPEED_TEST_SAMPLES_PARAMETER_NAME, &speedTestSamples)
+			if err != nil {
+				// TODO: log speed test parameter errors?
+				// This API param is not explicitly validated elsewhere.
+				continue
+			}
+
+			// As there must be at least one Range bound, there must be data to aggregate.
+			if len(speedTestSamples) == 0 {
+				continue
+			}
+
+			if aggregatedValues == nil {
+				aggregatedValues = make(map[string]int)
+			}
+
+			// Note: here we could filter out outliers such as samples that are unusually old
+			// or client/endPoint region pair too distant.
+
+			// aggregate may mutate (sort) the speedTestSamples slice.
+			value := aggregate(
+				filteredTactics.Filter.SpeedTestRTTMilliseconds.Aggregation,
+				speedTestSamples,
+				aggregatedValues)
+
+			if filteredTactics.Filter.SpeedTestRTTMilliseconds.AtLeast != nil &&
+				value < *filteredTactics.Filter.SpeedTestRTTMilliseconds.AtLeast {
+				continue
+			}
+			if filteredTactics.Filter.SpeedTestRTTMilliseconds.AtMost != nil &&
+				value > *filteredTactics.Filter.SpeedTestRTTMilliseconds.AtMost {
+				continue
+			}
+		}
+
+		tactics.merge(&filteredTactics.Tactics)
+
+		// Continue to apply more matches. Last matching tactics has priority for any field.
+	}
+
+	marshaledTactics, err := json.Marshal(tactics)
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	// MD5 hash is used solely as a data checksum and not for any security purpose.
+	digest := md5.Sum(marshaledTactics)
+	tag := hex.EncodeToString(digest[:])
+
+	payload := &Payload{
+		Tag: tag,
+	}
+
+	// New clients should always send STORED_TACTICS_TAG_PARAMETER_NAME. When they have no
+	// stored tactics, the stored tag will be "" and not match payload.Tag and payload.Tactics
+	// will be sent.
+	//
+	// When new clients send a stored tag that matches payload.Tag, the client already has
+	// the correct data and payload.Tactics is not sent.
+	//
+	// Old clients will not send STORED_TACTICS_TAG_PARAMETER_NAME. In this case, do not
+	// send payload.Tactics as the client will not use it, will not store it, will not send
+	// back the new tag and so the handshake response will always contain wasteful tactics
+	// data.
+
+	sendPayloadTactics := true
+
+	clientStoredTag, err := getStringRequestParam(apiParams, STORED_TACTICS_TAG_PARAMETER_NAME)
+
+	// Old client or new client with same tag.
+	if err != nil || payload.Tag == clientStoredTag {
+		sendPayloadTactics = false
+	}
+
+	if sendPayloadTactics {
+		payload.Tactics = marshaledTactics
+	}
+
+	return payload, nil
+}
+
+// TODO: refactor this copy of psiphon/server.getStringRequestParam into common?
+func getStringRequestParam(params common.APIParameters, name string) (string, error) {
+	if params[name] == nil {
+		return "", common.ContextError(fmt.Errorf("missing param: %s", name))
+	}
+	value, ok := params[name].(string)
+	if !ok {
+		return "", common.ContextError(fmt.Errorf("invalid param: %s", name))
+	}
+	return value, nil
+}
+
+func getJSONRequestParam(params common.APIParameters, name string, value interface{}) error {
+	if params[name] == nil {
+		return common.ContextError(fmt.Errorf("missing param: %s", name))
+	}
+
+	// Remarshal the parameter from common.APIParameters, as the initial API parameter
+	// unmarshal will not have known the correct target type. I.e., instead of doing
+	// unmarhsal-into-struct, common.APIParameters will have an unmarshal-into-interface
+	// value as described here: https://golang.org/pkg/encoding/json/#Unmarshal.
+
+	jsonValue, err := json.Marshal(params[name])
+	if err != nil {
+		return common.ContextError(err)
+	}
+	err = json.Unmarshal(jsonValue, value)
+	if err != nil {
+		return common.ContextError(err)
+	}
+	return nil
+}
+
+// aggregate may mutate (sort) the speedTestSamples slice.
+func aggregate(
+	aggregation string,
+	speedTestSamples []SpeedTestSample,
+	aggregatedValues map[string]int) int {
+
+	// Aggregated values are memoized to save recalculating for each filter.
+	if value, ok := aggregatedValues[aggregation]; ok {
+		return value
+	}
+
+	var value int
+
+	switch aggregation {
+	case AGGREGATION_MINIMUM:
+		value = minimumSampleRTTMilliseconds(speedTestSamples)
+	case AGGREGATION_MAXIMUM:
+		value = maximumSampleRTTMilliseconds(speedTestSamples)
+	case AGGREGATION_MEDIAN:
+		value = medianSampleRTTMilliseconds(speedTestSamples)
+	default:
+		return 0
+	}
+
+	aggregatedValues[aggregation] = value
+	return value
+}
+
+func minimumSampleRTTMilliseconds(samples []SpeedTestSample) int {
+
+	if len(samples) == 0 {
+		return 0
+	}
+	min := 0
+	for i := 1; i < len(samples); i++ {
+		if samples[i].RTTMilliseconds < samples[min].RTTMilliseconds {
+			min = i
+		}
+	}
+	return samples[min].RTTMilliseconds
+}
+
+func maximumSampleRTTMilliseconds(samples []SpeedTestSample) int {
+
+	if len(samples) == 0 {
+		return 0
+	}
+	max := 0
+	for i := 1; i < len(samples); i++ {
+		if samples[i].RTTMilliseconds > samples[max].RTTMilliseconds {
+			max = i
+		}
+	}
+	return samples[max].RTTMilliseconds
+}
+
+func medianSampleRTTMilliseconds(samples []SpeedTestSample) int {
+
+	if len(samples) == 0 {
+		return 0
+	}
+
+	// This in-place sort mutates the input slice.
+	sort.Slice(
+		samples,
+		func(i, j int) bool {
+			return samples[i].RTTMilliseconds < samples[j].RTTMilliseconds
+		})
+
+	// See: https://en.wikipedia.org/wiki/Median#Easy_explanation_of_the_sample_median
+
+	mid := len(samples) / 2
+
+	if len(samples)%2 == 1 {
+		return samples[mid].RTTMilliseconds
+	}
+
+	return (samples[mid-1].RTTMilliseconds + samples[mid].RTTMilliseconds) / 2
+}
+
+func (t *Tactics) clone() *Tactics {
+
+	u := &Tactics{
+		TTL:         t.TTL,
+		Probability: t.Probability,
+	}
+
+	// Note: there is no deep copy of parameter values; the the returned
+	// Tactics shares memory with the original and it individual parameters
+	// should not be modified.
+	if t.Parameters != nil {
+		u.Parameters = make(map[string]interface{})
+		for k, v := range t.Parameters {
+			u.Parameters[k] = v
+		}
+	}
+
+	return u
+}
+
+func (t *Tactics) merge(u *Tactics) {
+
+	if u.TTL != "" {
+		t.TTL = u.TTL
+	}
+
+	if u.Probability != 0.0 {
+		t.Probability = u.Probability
+	}
+
+	// Note: there is no deep copy of parameter values; the the returned
+	// Tactics shares memory with the original and it individual parameters
+	// should not be modified.
+	if u.Parameters != nil {
+		if t.Parameters == nil {
+			t.Parameters = make(map[string]interface{})
+		}
+		for k, v := range u.Parameters {
+			t.Parameters[k] = v
+		}
+	}
+}
+
+// HandleEndPoint routes the request to either handleSpeedTestRequest
+// or handleTacticsRequest; or returns false if not handled.
+func (server *Server) HandleEndPoint(
+	endPoint string,
+	geoIPData common.GeoIPData,
+	w http.ResponseWriter,
+	r *http.Request) bool {
+
+	switch endPoint {
+	case SPEED_TEST_END_POINT:
+		server.handleSpeedTestRequest(geoIPData, w, r)
+		return true
+	case TACTICS_END_POINT:
+		server.handleTacticsRequest(geoIPData, w, r)
+		return true
+	default:
+		return false
+	}
+}
+
+func (server *Server) handleSpeedTestRequest(
+	_ common.GeoIPData, w http.ResponseWriter, r *http.Request) {
+
+	_, err := ioutil.ReadAll(http.MaxBytesReader(w, r.Body, MAX_REQUEST_BODY_SIZE))
+	if err != nil {
+		server.logger.WithContextFields(
+			common.LogFields{"error": err}).Warning("failed to read request body")
+		w.WriteHeader(http.StatusNotFound)
+		return
+	}
+
+	randomPadding, err := common.MakeSecureRandomPadding(
+		SPEED_TEST_PADDING_MIN_SIZE, SPEED_TEST_PADDING_MAX_SIZE)
+	if err != nil {
+		server.logger.WithContextFields(
+			common.LogFields{"error": err}).Warning("failed to generate response")
+		randomPadding = make([]byte, 0)
+	}
+
+	w.WriteHeader(http.StatusOK)
+	w.Write(randomPadding)
+}
+
+func (server *Server) handleTacticsRequest(
+	geoIPData common.GeoIPData, w http.ResponseWriter, r *http.Request) {
+
+	server.ReloadableFile.RLock()
+	requestPrivateKey := server.RequestPrivateKey
+	requestObfuscatedKey := server.RequestObfuscatedKey
+	server.ReloadableFile.RUnlock()
+
+	// Read, decode, and unbox request payload.
+
+	boxedRequest, err := ioutil.ReadAll(http.MaxBytesReader(w, r.Body, MAX_REQUEST_BODY_SIZE))
+	if err != nil {
+		server.logger.WithContextFields(
+			common.LogFields{"error": err}).Warning("failed to read request body")
+		w.WriteHeader(http.StatusNotFound)
+		return
+	}
+
+	var apiParams common.APIParameters
+	bundledPeerPublicKey, err := unboxPayload(
+		TACTICS_REQUEST_NONCE,
+		nil,
+		requestPrivateKey,
+		requestObfuscatedKey,
+		boxedRequest,
+		&apiParams)
+	if err != nil {
+		server.logger.WithContextFields(
+			common.LogFields{"error": err}).Warning("failed to unbox request")
+		w.WriteHeader(http.StatusNotFound)
+		return
+	}
+
+	err = server.apiParameterValidator(apiParams)
+	if err != nil {
+		server.logger.WithContextFields(
+			common.LogFields{"error": err}).Warning("invalid request parameters")
+		w.WriteHeader(http.StatusNotFound)
+		return
+	}
+
+	tacticsPayload, err := server.GetTacticsPayload(geoIPData, apiParams)
+	if err != nil {
+		server.logger.WithContextFields(
+			common.LogFields{"error": err}).Warning("failed to get tactics")
+		w.WriteHeader(http.StatusNotFound)
+		return
+	}
+
+	// Marshal, box, and write response payload.
+
+	boxedResponse, err := boxPayload(
+		TACTICS_RESPONSE_NONCE,
+		bundledPeerPublicKey,
+		requestPrivateKey,
+		requestObfuscatedKey,
+		nil,
+		tacticsPayload)
+	if err != nil {
+		server.logger.WithContextFields(
+			common.LogFields{"error": err}).Warning("failed to box response")
+		w.WriteHeader(http.StatusNotFound)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+	w.Write(boxedResponse)
+
+	// Log a metric.
+
+	logFields := server.logFieldFormatter(geoIPData, apiParams)
+
+	logFields[NEW_TACTICS_TAG_LOG_FIELD_NAME] = tacticsPayload.Tag
+	logFields[IS_TACTICS_REQUEST_LOG_FIELD_NAME] = true
+
+	server.logger.LogMetric(TACTICS_METRIC_EVENT_NAME, logFields)
+}
+
+// RoundTripper performs a round trip to the specified endpoint, sending the
+// request body and returning the response body. The context may be used to
+// set a timeout or cancel the rount trip.
+//
+// The Psiphon client provides a RoundTripper using meek. The client will
+// handle connection details including server selection, dialing details
+// including device binding and upstream proxy, etc.
+type RoundTripper func(
+	ctx context.Context,
+	endPoint string,
+	requestBody []byte) ([]byte, error)
+
+// Storer provides a facility to persist tactics and speed test data.
+type Storer interface {
+	SetTacticsRecord(networkID string, record []byte) error
+	GetTacticsRecord(networkID string) ([]byte, error)
+	SetSpeedTestSamplesRecord(networkID string, record []byte) error
+	GetSpeedTestSamplesRecord(networkID string) ([]byte, error)
+}
+
+// SetTacticsAPIParameters populates apiParams with the additional
+// parameters for tactics. This is used by the Psiphon client when
+// preparing its handshake request.
+func SetTacticsAPIParameters(
+	clientParameters *parameters.ClientParameters,
+	storer Storer,
+	networkID string,
+	apiParams common.APIParameters) error {
+
+	// TODO: store the tag in its own record to avoid loading the whole tactics record?
+
+	record, err := getStoredTacticsRecord(storer, networkID)
+	if err != nil {
+		return common.ContextError(err)
+	}
+
+	speedTestSamples, err := getSpeedTestSamples(storer, networkID)
+	if err != nil {
+		return common.ContextError(err)
+	}
+
+	apiParams[STORED_TACTICS_TAG_PARAMETER_NAME] = record.Tag
+	apiParams[SPEED_TEST_SAMPLES_PARAMETER_NAME] = speedTestSamples
+
+	return nil
+}
+
+// HandleTacticsPayload updates the stored tactics with the given payload.
+// If the payload has a new tag/tactics, this is stored and a new expiry
+// time is set. If the payload has the same tag, the existing tactics are
+// retained and the exipry is extended using the previous TTL.
+// HandleTacticsPayload is called by the Psiphon client to handle the
+// tactics payload in the handshake response.
+func HandleTacticsPayload(
+	storer Storer,
+	networkID string,
+	payload *Payload) (*Record, error) {
+
+	// Note: since, in the client, a tactics request and a handshake
+	// request could be in flight concurrently, there exists a possibility
+	// that one clobbers the others result, and the clobbered result may
+	// be newer.
+	//
+	// However:
+	// - in the Storer, the tactics record is a single key/value, so its
+	//   elements are updated atomically;
+	// - the client Controller typically stops/aborts any outstanding
+	//   tactics request before the handshake
+	// - this would have to be concurrent with a tactics configuration hot
+	//   reload on the server
+	// - old and new tactics should both be valid
+
+	record, err := getStoredTacticsRecord(storer, networkID)
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	err = applyTacticsPayload(storer, networkID, record, payload)
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	// TODO: if tags match, just set an expiry record, not the whole tactics record?
+
+	err = setStoredTacticsRecord(storer, networkID, record)
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	return record, nil
+}
+
+// UseStoredTactics checks for an unexpired stored tactics record for the
+// given network ID that may be used immediately. When there is no error
+// and the record is nil, the caller should proceed with FetchTactics.
+//
+// When used, Record.Tag should be reported as the applied tactics tag.
+func UseStoredTactics(
+	storer Storer, networkID string) (*Record, error) {
+
+	record, err := getStoredTacticsRecord(storer, networkID)
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	if record != nil && record.Tag != "" && record.Expiry.After(time.Now()) {
+		return record, nil
+	}
+
+	return nil, nil
+}
+
+// FetchTactics performs a tactics request. When there are no stored
+// speed test samples for the network ID, a speed test request is
+// performed immediately before the tactics request, using the same
+// RoundTripper.
+//
+// The RoundTripper transport should be established in advance, so that
+// calls to RoundTripper don't take additional time in TCP, TLS, etc.
+// handshakes.
+//
+// The caller should first call UseStoredTactics and skip FetchTactics
+// when there is an unexpired stored tactics record available. The
+// caller is expected to set any overall timeout in the context input.
+//
+// FetchTactics modifies the apiParams input.
+func FetchTactics(
+	ctx context.Context,
+	clientParameters *parameters.ClientParameters,
+	storer Storer,
+	networkID string,
+	apiParams common.APIParameters,
+	endPointProtocol string,
+	endPointRegion string,
+	encodedRequestPublicKey string,
+	encodedRequestObfuscatedKey string,
+	roundTripper RoundTripper) (*Record, error) {
+
+	record, err := getStoredTacticsRecord(storer, networkID)
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	if record == nil {
+		record = &Record{}
+	}
+
+	speedTestSamples, err := getSpeedTestSamples(storer, networkID)
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	// Perform a speed test when there are no samples.
+
+	if len(speedTestSamples) == 0 {
+
+		p := clientParameters.Get()
+		randomPadding, err := common.MakeSecureRandomPadding(
+			p.Int(parameters.SpeedTestPaddingMinBytes),
+			p.Int(parameters.SpeedTestPaddingMaxBytes))
+		if err != nil {
+			// TODO: log MakeSecureRandomPadding failure?
+			randomPadding = make([]byte, 0)
+		}
+
+		startTime := monotime.Now()
+
+		response, err := roundTripper(ctx, SPEED_TEST_END_POINT, randomPadding)
+
+		elaspedTime := monotime.Since(startTime)
+
+		if err != nil {
+			return nil, common.ContextError(err)
+		}
+
+		sample := SpeedTestSample{
+			Timestamp:        time.Now(), // *TODO* use server time
+			EndPointRegion:   endPointRegion,
+			EndPointProtocol: endPointProtocol,
+			RTTMilliseconds:  int(elaspedTime / time.Millisecond),
+			BytesUp:          len(randomPadding),
+			BytesDown:        len(response),
+		}
+
+		err = AddSpeedTestSample(clientParameters, storer, networkID, sample)
+		if err != nil {
+			return nil, common.ContextError(err)
+		}
+
+		speedTestSamples, err = getSpeedTestSamples(storer, networkID)
+		if err != nil {
+			return nil, common.ContextError(err)
+		}
+	}
+
+	// Perform the tactics request.
+
+	apiParams[STORED_TACTICS_TAG_PARAMETER_NAME] = record.Tag
+	apiParams[SPEED_TEST_SAMPLES_PARAMETER_NAME] = speedTestSamples
+
+	requestPublicKey, err := base64.StdEncoding.DecodeString(encodedRequestPublicKey)
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	requestObfuscatedKey, err := base64.StdEncoding.DecodeString(encodedRequestObfuscatedKey)
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	ephemeralPublicKey, ephemeralPrivateKey, err := box.GenerateKey(rand.Reader)
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	boxedRequest, err := boxPayload(
+		TACTICS_REQUEST_NONCE,
+		requestPublicKey,
+		ephemeralPrivateKey[:],
+		requestObfuscatedKey,
+		ephemeralPublicKey[:],
+		&apiParams)
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	boxedResponse, err := roundTripper(ctx, TACTICS_END_POINT, boxedRequest)
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	// Process and store the response payload.
+
+	var payload *Payload
+
+	_, err = unboxPayload(
+		TACTICS_RESPONSE_NONCE,
+		requestPublicKey,
+		ephemeralPrivateKey[:],
+		requestObfuscatedKey,
+		boxedResponse,
+		&payload)
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	err = applyTacticsPayload(storer, networkID, record, payload)
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	err = setStoredTacticsRecord(storer, networkID, record)
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	return record, nil
+}
+
+// AddSpeedTestSample stores a new speed test sample. A maximum of
+// SpeedTestMaxSampleCount samples per network ID are stored, so once
+// that limit is reached, the oldest samples are removed to make room
+// for the new sample.
+func AddSpeedTestSample(
+	clientParameters *parameters.ClientParameters,
+	storer Storer,
+	networkID string,
+	sample SpeedTestSample) error {
+
+	maxCount := clientParameters.Get().Int(parameters.SpeedTestMaxSampleCount)
+	if maxCount == 0 {
+		return common.ContextError(errors.New("speed test max sample count is 0"))
+	}
+
+	speedTestSamples, err := getSpeedTestSamples(storer, networkID)
+	if err != nil {
+		return common.ContextError(err)
+	}
+
+	if speedTestSamples == nil {
+		speedTestSamples = make([]SpeedTestSample, 0)
+	}
+
+	if len(speedTestSamples)+1 > maxCount {
+		speedTestSamples = speedTestSamples[len(speedTestSamples)+1-maxCount:]
+	}
+	speedTestSamples = append(speedTestSamples, sample)
+
+	record, err := json.Marshal(speedTestSamples)
+	if err != nil {
+		return common.ContextError(err)
+	}
+
+	err = storer.SetSpeedTestSamplesRecord(networkID, record)
+	if err != nil {
+		return common.ContextError(err)
+	}
+
+	return nil
+}
+
+func getSpeedTestSamples(
+	storer Storer, networkID string) ([]SpeedTestSample, error) {
+
+	record, err := storer.GetSpeedTestSamplesRecord(networkID)
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	if record == nil {
+		return nil, nil
+	}
+
+	var speedTestSamples []SpeedTestSample
+	err = json.Unmarshal(record, &speedTestSamples)
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	return speedTestSamples, nil
+}
+
+func getStoredTacticsRecord(
+	storer Storer, networkID string) (*Record, error) {
+
+	marshaledRecord, err := storer.GetTacticsRecord(networkID)
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	if marshaledRecord == nil {
+		return nil, nil
+	}
+
+	var record *Record
+	err = json.Unmarshal(marshaledRecord, &record)
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	if record == nil {
+		record = &Record{}
+	}
+
+	return record, nil
+}
+
+func applyTacticsPayload(
+	storer Storer,
+	networkID string,
+	record *Record,
+	payload *Payload) error {
+
+	if payload.Tag == "" {
+		return common.ContextError(errors.New("invalid tag"))
+	}
+
+	// Replace the tactics data when the tags differ.
+
+	if payload.Tag != record.Tag {
+		record.Tag = payload.Tag
+		err := json.Unmarshal(payload.Tactics, &record.Tactics)
+		if err != nil {
+			return common.ContextError(err)
+		}
+	}
+
+	// Note: record.Tactics.TTL is validated by server
+	ttl, err := time.ParseDuration(record.Tactics.TTL)
+	if err != nil {
+		return common.ContextError(err)
+	}
+
+	if ttl <= 0 {
+		return common.ContextError(errors.New("invalid TTL"))
+	}
+	if record.Tactics.Probability <= 0.0 {
+		return common.ContextError(errors.New("invalid probability"))
+	}
+
+	// Set or extend the expiry.
+
+	record.Expiry = time.Now().Add(ttl)
+
+	return nil
+}
+
+func setStoredTacticsRecord(
+	storer Storer,
+	networkID string,
+	record *Record) error {
+
+	marshaledRecord, err := json.Marshal(record)
+	if err != nil {
+		return common.ContextError(err)
+	}
+
+	err = storer.SetTacticsRecord(networkID, marshaledRecord)
+	if err != nil {
+		return common.ContextError(err)
+	}
+
+	return nil
+}
+
+func boxPayload(
+	nonce, peerPublicKey, privateKey, obfuscatedKey, bundlePublicKey []byte,
+	payload interface{}) ([]byte, error) {
+
+	marshaledPayload, err := json.Marshal(payload)
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	var nonceArray [24]byte
+	copy(nonceArray[:], nonce)
+
+	var peerPublicKeyArray, privateKeyArray [32]byte
+	copy(peerPublicKeyArray[:], peerPublicKey[0:32])
+	copy(privateKeyArray[:], privateKey[0:32])
+
+	box := box.Seal(nil, marshaledPayload, &nonceArray, &peerPublicKeyArray, &privateKeyArray)
+
+	if bundlePublicKey != nil {
+		bundledBox := make([]byte, 32+len(box))
+		copy(bundledBox[0:32], bundlePublicKey[0:32])
+		copy(bundledBox[32:], box)
+		box = bundledBox
+	}
+
+	obfuscator, err := common.NewClientObfuscator(
+		&common.ObfuscatorConfig{
+			Keyword:    string(obfuscatedKey),
+			MaxPadding: TACTICS_PADDING_MAX_SIZE})
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	obfuscatedBox := obfuscator.SendSeedMessage()
+	seedLen := len(obfuscatedBox)
+
+	obfuscatedBox = append(obfuscatedBox, box...)
+	obfuscator.ObfuscateClientToServer(obfuscatedBox[seedLen:])
+
+	return obfuscatedBox, nil
+}
+
+// unboxPayload mutates obfuscatedBoxedPayload by deobfuscating in-place.
+func unboxPayload(
+	nonce, peerPublicKey, privateKey, obfuscatedKey, obfuscatedBoxedPayload []byte,
+	payload interface{}) ([]byte, error) {
+
+	obfuscatedReader := bytes.NewReader(obfuscatedBoxedPayload[:])
+
+	obfuscator, err := common.NewServerObfuscator(
+		obfuscatedReader,
+		&common.ObfuscatorConfig{Keyword: string(obfuscatedKey)})
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	seedLen, err := obfuscatedReader.Seek(0, 1)
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	boxedPayload := obfuscatedBoxedPayload[seedLen:]
+	obfuscator.ObfuscateClientToServer(boxedPayload)
+
+	var nonceArray [24]byte
+	copy(nonceArray[:], nonce)
+
+	var peerPublicKeyArray, privateKeyArray [32]byte
+	copy(privateKeyArray[:], privateKey[0:32])
+
+	var bundledPeerPublicKey []byte
+
+	if peerPublicKey != nil {
+		copy(peerPublicKeyArray[:], peerPublicKey[0:32])
+	} else {
+		if len(boxedPayload) < 32 {
+			return nil, common.ContextError(errors.New("unexpected box size"))
+		}
+		bundledPeerPublicKey = boxedPayload[0:32]
+		copy(peerPublicKeyArray[0:32], bundledPeerPublicKey)
+		boxedPayload = boxedPayload[32:]
+	}
+
+	marshaledPayload, ok := box.Open(nil, boxedPayload, &nonceArray, &peerPublicKeyArray, &privateKeyArray)
+	if !ok {
+		return nil, common.ContextError(errors.New("invalid box"))
+	}
+
+	err = json.Unmarshal(marshaledPayload, payload)
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	return bundledPeerPublicKey, nil
+}

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

@@ -0,0 +1,727 @@
+/*
+ * Copyright (c) 2018, 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 tactics
+
+import (
+	"bytes"
+	"context"
+	"crypto/rand"
+	"encoding/base64"
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"net"
+	"net/http"
+	"os"
+	"reflect"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/crypto/nacl/box"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
+)
+
+func TestTactics(t *testing.T) {
+
+	// Server tactics configuration
+
+	// Long and short region lists test both map and slice lookups
+	// Repeated median aggregation tests aggregation memoization
+
+	tacticsConfigTemplate := `
+    {
+      "RequestPublicKey" : "%s",
+      "RequestPrivateKey" : "%s",
+      "RequestObfuscatedKey" : "%s",
+      "DefaultTactics" : {
+        "TTL" : "1s",
+        "Probability" : %0.1f,
+        "Parameters" : {
+          "NetworkLatencyMultiplier" : %0.1f
+        }
+      },
+      "FilteredTactics" : [
+        {
+          "Filter" : {
+            "Regions": ["R1", "R2", "R3", "R4", "R5", "R6"],
+            "APIParameters" : {"client_platform" : ["P1"]},
+            "SpeedTestRTTMilliseconds" : {
+              "Aggregation" : "Median",
+              "AtLeast" : 1
+            }
+          },
+          "Tactics" : {
+            "Parameters" : {
+              "ConnectionWorkerPoolSize" : %d
+            }
+          }
+        },
+        {
+          "Filter" : {
+            "Regions": ["R1"],
+            "APIParameters" : {"client_platform" : ["P1"], "client_version": ["V1"]},
+            "SpeedTestRTTMilliseconds" : {
+              "Aggregation" : "Median",
+              "AtLeast" : 1
+            }
+          },
+          "Tactics" : {
+            "Parameters" : {
+              "LimitTunnelProtocols" : %s
+            }
+          }
+        },
+        {
+          "Filter" : {
+            "Regions": ["R2"]
+          },
+          "Tactics" : {
+            "Parameters" : {
+              "ConnectionWorkerPoolSize" : %d
+            }
+          }
+        }
+      ]
+    }
+    `
+	if lookupThreshold != 5 {
+		t.Fatalf("unexpected lookupThreshold")
+	}
+
+	requestPublicKey, requestPrivateKey, err := box.GenerateKey(rand.Reader)
+	if err != nil {
+		t.Fatalf("GenerateKey failed: %s", err)
+	}
+
+	encodedRequestPublicKey := base64.StdEncoding.EncodeToString(requestPublicKey[:])
+	encodedRequestPrivateKey := base64.StdEncoding.EncodeToString(requestPrivateKey[:])
+
+	obfuscatedKey, err := common.MakeSecureRandomBytes(common.OBFUSCATE_KEY_LENGTH)
+	if err != nil {
+		t.Fatalf("MakeSecureRandomBytes failed: %s", err)
+	}
+
+	encodedObfuscatedKey := base64.StdEncoding.EncodeToString(obfuscatedKey[:])
+
+	tacticsProbability := 0.5
+	tacticsNetworkLatencyMultiplier := 2.0
+	tacticsConnectionWorkerPoolSize := 5
+	tacticsLimitTunnelProtocols := protocol.TunnelProtocols{"OSSH", "SSH"}
+	jsonTacticsLimitTunnelProtocols, _ := json.Marshal(tacticsLimitTunnelProtocols)
+
+	tacticsConfig := fmt.Sprintf(
+		tacticsConfigTemplate,
+		encodedRequestPublicKey,
+		encodedRequestPrivateKey,
+		encodedObfuscatedKey,
+		tacticsProbability,
+		tacticsNetworkLatencyMultiplier,
+		tacticsConnectionWorkerPoolSize,
+		jsonTacticsLimitTunnelProtocols,
+		tacticsConnectionWorkerPoolSize+1)
+
+	file, err := ioutil.TempFile("", "tactics.config")
+	if err != nil {
+		t.Fatalf("TempFile create failed: %s", err)
+	}
+	_, err = file.Write([]byte(tacticsConfig))
+	if err != nil {
+		t.Fatalf("TempFile write failed: %s", err)
+	}
+	file.Close()
+
+	configFileName := file.Name()
+	defer os.Remove(configFileName)
+
+	// Configure and run server
+
+	// Mock server uses an insecure HTTP transport that exposes endpoint names
+
+	clientGeoIPData := common.GeoIPData{Country: "R1"}
+
+	logger := newTestLogger()
+
+	validator := func(
+		params common.APIParameters) error {
+
+		expectedParams := []string{"client_platform", "client_version"}
+		for _, name := range expectedParams {
+			value, ok := params[name]
+			if !ok {
+				return fmt.Errorf("missing param: %s", name)
+			}
+			_, ok = value.(string)
+			if !ok {
+				return fmt.Errorf("invalid param type: %s", name)
+			}
+		}
+		return nil
+	}
+
+	formatter := func(
+		geoIPData common.GeoIPData,
+		params common.APIParameters) common.LogFields {
+
+		return common.LogFields(params)
+	}
+
+	server, err := NewServer(
+		logger,
+		formatter,
+		validator,
+		configFileName)
+	if err != nil {
+		t.Fatalf("NewServer failed: %s", err)
+	}
+
+	listener, err := net.Listen("tcp", "127.0.0.1:0")
+	if err != nil {
+		t.Fatalf("Listen failed: %s", err)
+	}
+
+	serverAddress := listener.Addr().String()
+
+	go func() {
+		serveMux := http.NewServeMux()
+		serveMux.HandleFunc(
+			"/",
+			func(w http.ResponseWriter, r *http.Request) {
+				// Ensure RTT takes at least 1 millisecond for speed test
+				time.Sleep(1 * time.Millisecond)
+				endPoint := strings.Trim(r.URL.Path, "/")
+				if !server.HandleEndPoint(endPoint, clientGeoIPData, w, r) {
+					http.NotFound(w, r)
+				}
+			})
+		httpServer := &http.Server{
+			Addr:    serverAddress,
+			Handler: serveMux,
+		}
+		httpServer.Serve(listener)
+	}()
+
+	// Configure client
+
+	clientParams, err := parameters.NewClientParameters(
+		func(err error) {
+			t.Fatalf("ClientParameters getValue failed: %s", err)
+		})
+	if err != nil {
+		t.Fatalf("NewClientParameters failed: %s", err)
+	}
+
+	networkID := "NETWORK1"
+
+	apiParams := common.APIParameters{
+		"client_platform": "P1",
+		"client_version":  "V1"}
+
+	storer := newTestStorer()
+
+	endPointRegion := "R0"
+	endPointProtocol := "OSSH"
+	differentEndPointProtocol := "SSH"
+
+	roundTripper := func(
+		ctx context.Context,
+		endPoint string,
+		requestBody []byte) ([]byte, error) {
+
+		request, err := http.NewRequest(
+			"POST",
+			fmt.Sprintf("http://%s/%s", serverAddress, endPoint),
+			bytes.NewReader(requestBody))
+		if err != nil {
+			return nil, err
+		}
+		request = request.WithContext(ctx)
+		response, err := http.DefaultClient.Do(request)
+		if err != nil {
+			return nil, err
+		}
+		defer response.Body.Close()
+		if response.StatusCode != http.StatusOK {
+			return nil, fmt.Errorf("HTTP request failed: %d", response.StatusCode)
+		}
+		body, err := ioutil.ReadAll(response.Body)
+		if err != nil {
+			return nil, err
+		}
+		return body, nil
+	}
+
+	// There should be no local tactics
+
+	tacticsRecord, err := UseStoredTactics(storer, networkID)
+	if err != nil {
+		t.Fatalf("UseStoredTactics failed: %s", err)
+	}
+
+	if tacticsRecord != nil {
+		t.Fatalf("unexpected tactics record")
+	}
+
+	// Helper to check that expected tactics parameters are returned
+
+	checkParameters := func(r *Record) {
+
+		p, err := parameters.NewClientParameters(nil)
+		if err != nil {
+			t.Fatalf("NewClientParameters failed: %s", err)
+		}
+
+		if r.Tactics.Probability != tacticsProbability {
+			t.Fatalf("Unexpected probability: %f", r.Tactics.Probability)
+		}
+
+		// skipOnError is true for Psiphon clients
+		counts, err := p.Set(r.Tag, true, r.Tactics.Parameters)
+		if err != nil {
+			t.Fatalf("Apply failed: %s", err)
+		}
+
+		if counts[0] != 3 {
+			t.Fatalf("Unexpected apply count: %d", counts[0])
+		}
+
+		multipler := p.Get().Float(parameters.NetworkLatencyMultiplier)
+		if multipler != tacticsNetworkLatencyMultiplier {
+			t.Fatalf("Unexpected NetworkLatencyMultiplier: %v", multipler)
+		}
+
+		connectionWorkerPoolSize := p.Get().Int(parameters.ConnectionWorkerPoolSize)
+		if connectionWorkerPoolSize != tacticsConnectionWorkerPoolSize {
+			t.Fatalf("Unexpected ConnectionWorkerPoolSize: %v", connectionWorkerPoolSize)
+		}
+
+		limitTunnelProtocols := p.Get().TunnelProtocols(parameters.LimitTunnelProtocols)
+		if !reflect.DeepEqual(limitTunnelProtocols, tacticsLimitTunnelProtocols) {
+			t.Fatalf("Unexpected LimitTunnelProtocols: %v", limitTunnelProtocols)
+		}
+	}
+
+	// Initial tactics request; will also run a speed test
+
+	// Request should complete in < 1 second
+	ctx, cancelFunc := context.WithTimeout(context.Background(), 1*time.Second)
+
+	initialFetchTacticsRecord, err := FetchTactics(
+		ctx,
+		clientParams,
+		storer,
+		networkID,
+		apiParams,
+		endPointProtocol,
+		endPointRegion,
+		encodedRequestPublicKey,
+		encodedObfuscatedKey,
+		roundTripper)
+
+	cancelFunc()
+
+	if err != nil {
+		t.Fatalf("FetchTactics failed: %s", err)
+	}
+
+	if initialFetchTacticsRecord == nil {
+		t.Fatalf("expected tactics record")
+	}
+
+	checkParameters(initialFetchTacticsRecord)
+
+	// There should now be cached local tactics
+
+	storedTacticsRecord, err := UseStoredTactics(storer, networkID)
+	if err != nil {
+		t.Fatalf("UseStoredTactics failed: %s", err)
+	}
+
+	if storedTacticsRecord == nil {
+		t.Fatalf("expected stored tactics record")
+	}
+
+	// Strip monotonic component so comparisons will work
+	initialFetchTacticsRecord.Expiry = initialFetchTacticsRecord.Expiry.Round(0)
+
+	if !reflect.DeepEqual(initialFetchTacticsRecord, storedTacticsRecord) {
+		t.Fatalf("tactics records are not identical")
+	}
+
+	checkParameters(storedTacticsRecord)
+
+	// There should now be a speed test sample
+
+	speedTestSamples, err := getSpeedTestSamples(storer, networkID)
+	if err != nil {
+		t.Fatalf("getSpeedTestSamples failed: %s", err)
+	}
+
+	if len(speedTestSamples) != 1 {
+		t.Fatalf("unexpected speed test samples count")
+	}
+
+	// Wait for tactics to expire
+
+	time.Sleep(1 * time.Second)
+
+	storedTacticsRecord, err = UseStoredTactics(storer, networkID)
+	if err != nil {
+		t.Fatalf("UseStoredTactics failed: %s", err)
+	}
+
+	if storedTacticsRecord != nil {
+		t.Fatalf("unexpected stored tactics record")
+	}
+
+	// Next fetch should merge empty payload as tag matches
+	// TODO: inspect tactics response payload
+
+	fetchTacticsRecord, err := FetchTactics(
+		context.Background(),
+		clientParams,
+		storer,
+		networkID,
+		apiParams,
+		endPointProtocol,
+		endPointRegion,
+		encodedRequestPublicKey,
+		encodedObfuscatedKey,
+		roundTripper)
+	if err != nil {
+		t.Fatalf("FetchTactics failed: %s", err)
+	}
+
+	if fetchTacticsRecord == nil {
+		t.Fatalf("expected tactics record")
+	}
+
+	if initialFetchTacticsRecord.Tag != fetchTacticsRecord.Tag {
+		t.Fatalf("tags are not identical")
+	}
+
+	if initialFetchTacticsRecord.Expiry.Equal(fetchTacticsRecord.Expiry) {
+		t.Fatalf("expiries unexpectedly identical")
+	}
+
+	if !reflect.DeepEqual(initialFetchTacticsRecord.Tactics, fetchTacticsRecord.Tactics) {
+		t.Fatalf("tactics are not identical")
+	}
+
+	checkParameters(fetchTacticsRecord)
+
+	// Modify tactics configuration to change payload
+
+	tacticsConnectionWorkerPoolSize = 6
+
+	tacticsConfig = fmt.Sprintf(
+		tacticsConfigTemplate,
+		encodedRequestPublicKey,
+		encodedRequestPrivateKey,
+		encodedObfuscatedKey,
+		tacticsProbability,
+		tacticsNetworkLatencyMultiplier,
+		tacticsConnectionWorkerPoolSize,
+		jsonTacticsLimitTunnelProtocols,
+		tacticsConnectionWorkerPoolSize+1)
+
+	err = ioutil.WriteFile(configFileName, []byte(tacticsConfig), 0600)
+	if err != nil {
+		t.Fatalf("WriteFile failed: %s", err)
+	}
+
+	reloaded, err := server.Reload()
+	if err != nil {
+		t.Fatalf("Reload failed: %s", err)
+	}
+
+	if !reloaded {
+		t.Fatalf("Server config failed to reload")
+	}
+
+	// Next fetch should return a different payload
+
+	fetchTacticsRecord, err = FetchTactics(
+		context.Background(),
+		clientParams,
+		storer,
+		networkID,
+		apiParams,
+		endPointProtocol,
+		endPointRegion,
+		encodedRequestPublicKey,
+		encodedObfuscatedKey,
+		roundTripper)
+	if err != nil {
+		t.Fatalf("FetchTactics failed: %s", err)
+	}
+
+	if fetchTacticsRecord == nil {
+		t.Fatalf("expected tactics record")
+	}
+
+	if initialFetchTacticsRecord.Tag == fetchTacticsRecord.Tag {
+		t.Fatalf("tags unexpectedly identical")
+	}
+
+	if initialFetchTacticsRecord.Expiry.Equal(fetchTacticsRecord.Expiry) {
+		t.Fatalf("expires unexpectedly identical")
+	}
+
+	if reflect.DeepEqual(initialFetchTacticsRecord.Tactics, fetchTacticsRecord.Tactics) {
+		t.Fatalf("tactics unexpectedly identical")
+	}
+
+	checkParameters(fetchTacticsRecord)
+
+	// Exercise handshake transport of tactics
+
+	// Wait for tactics to expire; handshake should renew
+	time.Sleep(1 * time.Second)
+
+	handshakeParams := common.APIParameters{
+		"client_platform": "P1",
+		"client_version":  "V1"}
+
+	err = SetTacticsAPIParameters(clientParams, storer, networkID, handshakeParams)
+	if err != nil {
+		t.Fatalf("SetTacticsAPIParameters failed: %s", err)
+	}
+
+	tacticsPayload, err := server.GetTacticsPayload(clientGeoIPData, handshakeParams)
+	if err != nil {
+		t.Fatalf("GetTacticsPayload failed: %s", err)
+	}
+
+	handshakeTacticsRecord, err := HandleTacticsPayload(storer, networkID, tacticsPayload)
+	if err != nil {
+		t.Fatalf("HandleTacticsPayload failed: %s", err)
+	}
+
+	if handshakeTacticsRecord == nil {
+		t.Fatalf("expected tactics record")
+	}
+
+	if fetchTacticsRecord.Tag != handshakeTacticsRecord.Tag {
+		t.Fatalf("tags are not identical")
+	}
+
+	if fetchTacticsRecord.Expiry.Equal(handshakeTacticsRecord.Expiry) {
+		t.Fatalf("expiries unexpectedly identical")
+	}
+
+	if !reflect.DeepEqual(fetchTacticsRecord.Tactics, handshakeTacticsRecord.Tactics) {
+		t.Fatalf("tactics are not identical")
+	}
+
+	checkParameters(handshakeTacticsRecord)
+
+	// Now there should be stored tactics
+
+	storedTacticsRecord, err = UseStoredTactics(storer, networkID)
+	if err != nil {
+		t.Fatalf("UseStoredTactics failed: %s", err)
+	}
+
+	if storedTacticsRecord == nil {
+		t.Fatalf("expected stored tactics record")
+	}
+
+	handshakeTacticsRecord.Expiry = handshakeTacticsRecord.Expiry.Round(0)
+
+	if !reflect.DeepEqual(handshakeTacticsRecord, storedTacticsRecord) {
+		t.Fatalf("tactics records are not identical")
+	}
+
+	checkParameters(storedTacticsRecord)
+
+	// Change network ID, should be no stored tactics
+
+	networkID = "NETWORK2"
+
+	storedTacticsRecord, err = UseStoredTactics(storer, networkID)
+	if err != nil {
+		t.Fatalf("UseStoredTactics failed: %s", err)
+	}
+
+	if storedTacticsRecord != nil {
+		t.Fatalf("unexpected stored tactics record")
+	}
+
+	// Exercise speed test sample truncation
+
+	maxSamples := clientParams.Get().Int(parameters.SpeedTestMaxSampleCount)
+
+	for i := 0; i < maxSamples*2; i++ {
+
+		sample := SpeedTestSample{
+			EndPointProtocol: differentEndPointProtocol,
+		}
+
+		err = AddSpeedTestSample(clientParams, storer, networkID, sample)
+		if err != nil {
+			t.Fatalf("AddSpeedTestSample failed: %s", err)
+		}
+	}
+
+	speedTestSamples, err = getSpeedTestSamples(storer, networkID)
+	if err != nil {
+		t.Fatalf("getSpeedTestSamples failed: %s", err)
+	}
+
+	if len(speedTestSamples) != maxSamples {
+		t.Fatalf("unexpected speed test samples count")
+	}
+
+	for _, sample := range speedTestSamples {
+		if sample.EndPointProtocol == endPointProtocol {
+			t.Fatalf("unexpected old speed test sample")
+		}
+	}
+
+	// Fetch should fail when using incorrect keys
+
+	incorrectRequestPublicKey, _, err := box.GenerateKey(rand.Reader)
+	if err != nil {
+		t.Fatalf("GenerateKey failed: %s", err)
+	}
+
+	encodedIncorrectRequestPublicKey := base64.StdEncoding.EncodeToString(incorrectRequestPublicKey[:])
+
+	incorrectObfuscatedKey, err := common.MakeSecureRandomBytes(common.OBFUSCATE_KEY_LENGTH)
+	if err != nil {
+		t.Fatalf("MakeSecureRandomBytes failed: %s", err)
+	}
+
+	encodedIncorrectObfuscatedKey := base64.StdEncoding.EncodeToString(incorrectObfuscatedKey[:])
+
+	_, err = FetchTactics(
+		context.Background(),
+		clientParams,
+		storer,
+		networkID,
+		apiParams,
+		endPointProtocol,
+		endPointRegion,
+		encodedIncorrectRequestPublicKey,
+		encodedObfuscatedKey,
+		roundTripper)
+	if err == nil {
+		t.Fatalf("FetchTactics succeeded unexpectedly with incorrect request key")
+	}
+
+	_, err = FetchTactics(
+		context.Background(),
+		clientParams,
+		storer,
+		networkID,
+		apiParams,
+		endPointProtocol,
+		endPointRegion,
+		encodedRequestPublicKey,
+		encodedIncorrectObfuscatedKey,
+		roundTripper)
+	if err == nil {
+		t.Fatalf("FetchTactics succeeded unexpectedly with incorrect obfuscated key")
+	}
+
+	// TODO: test replay attack defence
+
+	// TODO: test Server.Validate with invalid tactics configurations
+}
+
+type testStorer struct {
+	tacticsRecords         map[string][]byte
+	speedTestSampleRecords map[string][]byte
+}
+
+func newTestStorer() *testStorer {
+	return &testStorer{
+		tacticsRecords:         make(map[string][]byte),
+		speedTestSampleRecords: make(map[string][]byte),
+	}
+}
+
+func (s *testStorer) SetTacticsRecord(networkID string, record []byte) error {
+	s.tacticsRecords[networkID] = record
+	return nil
+}
+
+func (s *testStorer) GetTacticsRecord(networkID string) ([]byte, error) {
+	return s.tacticsRecords[networkID], nil
+}
+
+func (s *testStorer) SetSpeedTestSamplesRecord(networkID string, record []byte) error {
+	s.speedTestSampleRecords[networkID] = record
+	return nil
+}
+
+func (s *testStorer) GetSpeedTestSamplesRecord(networkID string) ([]byte, error) {
+	return s.speedTestSampleRecords[networkID], nil
+}
+
+type testLogger struct {
+}
+
+func newTestLogger() *testLogger {
+	return &testLogger{}
+}
+
+func (l *testLogger) WithContext() common.LogContext {
+	return &testLoggerContext{context: common.GetParentContext()}
+}
+
+func (l *testLogger) WithContextFields(fields common.LogFields) common.LogContext {
+	return &testLoggerContext{
+		context: common.GetParentContext(),
+		fields:  fields,
+	}
+}
+
+func (l *testLogger) LogMetric(metric string, fields common.LogFields) {
+	fmt.Printf("METRIC: %s: fields=%+v\n", metric, fields)
+}
+
+type testLoggerContext struct {
+	context string
+	fields  common.LogFields
+}
+
+func (l *testLoggerContext) log(priority, message string) {
+	fmt.Printf("%s: %s: %s fields=%+v\n", priority, l.context, message, l.fields)
+}
+
+func (l *testLoggerContext) Debug(args ...interface{}) {
+	l.log("DEBUG", fmt.Sprint(args...))
+}
+
+func (l *testLoggerContext) Info(args ...interface{}) {
+	l.log("INFO", fmt.Sprint(args...))
+}
+
+func (l *testLoggerContext) Warning(args ...interface{}) {
+	l.log("WARNING", fmt.Sprint(args...))
+}
+
+func (l *testLoggerContext) Error(args ...interface{}) {
+	l.log("ERROR", fmt.Sprint(args...))
+}

+ 29 - 8
psiphon/common/utils.go

@@ -49,7 +49,7 @@ func Contains(list []string, target string) bool {
 }
 
 // ContainsAny returns true if any string in targets
-// is present ini he list.
+// is present in the list.
 func ContainsAny(list, targets []string) bool {
 	for _, target := range targets {
 		if Contains(list, target) {
@@ -71,14 +71,34 @@ func ContainsInt(list []int, target int) bool {
 }
 
 // FlipCoin is a helper function that randomly
-// returns true or false. If the underlying random
-// number generator fails, FlipCoin still returns
-// a result.
+// returns true or false.
+//
+// If the underlying random number generator fails,
+// FlipCoin still returns a result.
 func FlipCoin() bool {
 	randomInt, _ := MakeSecureRandomInt(2)
 	return randomInt == 1
 }
 
+// FlipWeightedCoin returns the result of a weighted
+// random coin flip. If the weight is 0.5, the outcome
+// is equally likely to be true or false. If the weight
+// is 1.0, the outcome is always true, and if the
+// weight is 0.0, the outcome is always false.
+//
+// Input weights > 1.0 are treated as 1.0.
+//
+// If the underlying random number generator fails,
+// FlipWeightedCoin still returns a result.
+func FlipWeightedCoin(weight float64) bool {
+	if weight > 1.0 {
+		weight = 1.0
+	}
+	n, _ := MakeSecureRandomInt64(math.MaxInt64)
+	f := float64(n) / float64(math.MaxInt64)
+	return f > 1.0-weight
+}
+
 // MakeSecureRandomInt is a helper function that wraps
 // MakeSecureRandomInt64.
 func MakeSecureRandomInt(max int) (int, error) {
@@ -89,6 +109,9 @@ func MakeSecureRandomInt(max int) (int, error) {
 // MakeSecureRandomInt64 is a helper function that wraps
 // crypto/rand.Int, which returns a uniform random value in [0, max).
 func MakeSecureRandomInt64(max int64) (int64, error) {
+	if max <= 0 {
+		return 0, nil
+	}
 	randomInt, err := rand.Int(rand.Reader, big.NewInt(max))
 	if err != nil {
 		return 0, ContextError(err)
@@ -112,8 +135,7 @@ func MakeSecureRandomBytes(length int) ([]byte, error) {
 
 // MakeSecureRandomPadding selects a random padding length in the indicated
 // range and returns a random byte array of the selected length.
-// In the unlikely case where an underlying MakeRandom functions fails,
-// the padding is length 0.
+// If maxLength <= minLength, the padding is minLength.
 func MakeSecureRandomPadding(minLength, maxLength int) ([]byte, error) {
 	var padding []byte
 	paddingSize, err := MakeSecureRandomInt(maxLength - minLength)
@@ -129,8 +151,7 @@ func MakeSecureRandomPadding(minLength, maxLength int) ([]byte, error) {
 }
 
 // MakeRandomPeriod returns a random duration, within a given range.
-// In the unlikely case where an underlying MakeRandom functions fails,
-// the period is the minimum.
+// If max <= min, the duration is min.
 func MakeRandomPeriod(min, max time.Duration) (time.Duration, error) {
 	period, err := MakeSecureRandomInt64(max.Nanoseconds() - min.Nanoseconds())
 	if err != nil {

+ 38 - 0
psiphon/common/utils_test.go

@@ -134,3 +134,41 @@ func TestFormatByteCount(t *testing.T) {
 		})
 	}
 }
+
+func TestWeightedCoinFlip(t *testing.T) {
+
+	runs := 100000
+	tolerance := 1000
+
+	testCases := []struct {
+		weight        float64
+		expectedTrues int
+	}{
+		{0.333, runs / 3},
+		{0.5, runs / 2},
+		{1.0, runs},
+		{0.0, 0},
+	}
+
+	for _, testCase := range testCases {
+		t.Run(fmt.Sprintf("%f", testCase.weight), func(t *testing.T) {
+			trues := 0
+			for i := 0; i < runs; i++ {
+				if FlipWeightedCoin(testCase.weight) {
+					trues++
+				}
+			}
+
+			min := testCase.expectedTrues - tolerance
+			if min < 0 {
+				min = 0
+			}
+			max := testCase.expectedTrues + tolerance
+
+			if trues < min || trues > max {
+				t.Errorf("unexpected coin flip outcome: %f %d (+/-%d) %d",
+					testCase.weight, testCase.expectedTrues, tolerance, trues)
+			}
+		})
+	}
+}

Разница между файлами не показана из-за своего большого размера
+ 341 - 506
psiphon/config.go


+ 0 - 166
psiphon/config_test.go

@@ -20,7 +20,6 @@
 package psiphon
 
 import (
-	"encoding/base64"
 	"encoding/json"
 	"io/ioutil"
 	"strings"
@@ -158,168 +157,3 @@ func (suite *ConfigTestSuite) Test_LoadConfig_GoodJson() {
 	_, err = LoadConfig(testObjJSON)
 	suite.Nil(err, "JSON with null for optional values should succeed")
 }
-
-func TestDownloadURLs(t *testing.T) {
-
-	decodedA := "a.example.com"
-	encodedA := base64.StdEncoding.EncodeToString([]byte(decodedA))
-	encodedB := base64.StdEncoding.EncodeToString([]byte("b.example.com"))
-	encodedC := base64.StdEncoding.EncodeToString([]byte("c.example.com"))
-
-	testCases := []struct {
-		description                string
-		downloadURLs               []*DownloadURL
-		attempts                   int
-		expectedValid              bool
-		expectedCanonicalURL       string
-		expectedDistinctSelections int
-	}{
-		{
-			"missing OnlyAfterAttempts = 0",
-			[]*DownloadURL{
-				{
-					URL:               encodedA,
-					OnlyAfterAttempts: 1,
-				},
-			},
-			1,
-			false,
-			decodedA,
-			0,
-		},
-		{
-			"single URL, multiple attempts",
-			[]*DownloadURL{
-				{
-					URL:               encodedA,
-					OnlyAfterAttempts: 0,
-				},
-			},
-			2,
-			true,
-			decodedA,
-			1,
-		},
-		{
-			"multiple URLs, single attempt",
-			[]*DownloadURL{
-				{
-					URL:               encodedA,
-					OnlyAfterAttempts: 0,
-				},
-				{
-					URL:               encodedB,
-					OnlyAfterAttempts: 1,
-				},
-				{
-					URL:               encodedC,
-					OnlyAfterAttempts: 1,
-				},
-			},
-			1,
-			true,
-			decodedA,
-			1,
-		},
-		{
-			"multiple URLs, multiple attempts",
-			[]*DownloadURL{
-				{
-					URL:               encodedA,
-					OnlyAfterAttempts: 0,
-				},
-				{
-					URL:               encodedB,
-					OnlyAfterAttempts: 1,
-				},
-				{
-					URL:               encodedC,
-					OnlyAfterAttempts: 1,
-				},
-			},
-			2,
-			true,
-			decodedA,
-			3,
-		},
-		{
-			"multiple URLs, multiple attempts",
-			[]*DownloadURL{
-				{
-					URL:               encodedA,
-					OnlyAfterAttempts: 0,
-				},
-				{
-					URL:               encodedB,
-					OnlyAfterAttempts: 1,
-				},
-				{
-					URL:               encodedC,
-					OnlyAfterAttempts: 3,
-				},
-			},
-			4,
-			true,
-			decodedA,
-			3,
-		},
-	}
-
-	for _, testCase := range testCases {
-		t.Run(testCase.description, func(t *testing.T) {
-
-			err := decodeAndValidateDownloadURLs(
-				testCase.description,
-				testCase.downloadURLs)
-
-			if testCase.expectedValid {
-				if err != nil {
-					t.Fatalf("unexpected validation error: %s", err)
-				}
-			} else {
-				if err == nil {
-					t.Fatalf("expected validation error")
-				}
-				return
-			}
-
-			// Track distinct selections for each attempt; the
-			// expected number of distinct should be for at least
-			// one particular attempt.
-			attemptDistinctSelections := make(map[int]map[string]int)
-			for i := 0; i < testCase.attempts; i++ {
-				attemptDistinctSelections[i] = make(map[string]int)
-			}
-
-			// Perform enough runs to account for random selection.
-			runs := 1000
-
-			attempt := 0
-			for i := 0; i < runs; i++ {
-				url, canonicalURL, skipVerify := selectDownloadURL(attempt, testCase.downloadURLs)
-				if canonicalURL != testCase.expectedCanonicalURL {
-					t.Fatalf("unexpected canonical URL: %s", canonicalURL)
-				}
-				if skipVerify {
-					t.Fatalf("expected skipVerify")
-				}
-				attemptDistinctSelections[attempt][url] += 1
-				attempt = (attempt + 1) % testCase.attempts
-			}
-
-			maxDistinctSelections := 0
-			for _, m := range attemptDistinctSelections {
-				if len(m) > maxDistinctSelections {
-					maxDistinctSelections = len(m)
-				}
-			}
-
-			if maxDistinctSelections != testCase.expectedDistinctSelections {
-				t.Fatalf("got %d distinct selections, expected %d",
-					maxDistinctSelections,
-					testCase.expectedDistinctSelections)
-			}
-		})
-	}
-
-}

+ 402 - 88
psiphon/controller.go

@@ -34,7 +34,9 @@ import (
 
 	"github.com/Psiphon-Inc/goarista/monotime"
 	"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/protocol"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/tactics"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/tun"
 )
 
@@ -80,6 +82,8 @@ type Controller struct {
 type candidateServerEntry struct {
 	serverEntry                *protocol.ServerEntry
 	isServerAffinityCandidate  bool
+	usePriorityProtocol        bool
+	impairedProtocols          []string
 	adjustedEstablishStartTime monotime.Time
 }
 
@@ -94,7 +98,7 @@ func NewController(config *Config) (controller *Controller, err error) {
 	NoticeSessionId(config.SessionID)
 
 	untunneledDialConfig := &DialConfig{
-		UpstreamProxyUrl:              config.UpstreamProxyUrl,
+		UpstreamProxyURL:              config.UpstreamProxyURL,
 		CustomHeaders:                 config.CustomHeaders,
 		DeviceBinder:                  config.DeviceBinder,
 		DnsServerGetter:               config.DnsServerGetter,
@@ -210,17 +214,12 @@ func (controller *Controller) Run(ctx context.Context) {
 
 	if !controller.config.DisableRemoteServerListFetcher {
 
-		retryPeriod := time.Duration(
-			*controller.config.FetchRemoteServerListRetryPeriodSeconds) * time.Second
-
 		if controller.config.RemoteServerListURLs != nil {
 			controller.runWaitGroup.Add(1)
 			go controller.remoteServerListFetcher(
 				"common",
 				FetchCommonRemoteServerList,
-				controller.signalFetchCommonRemoteServerList,
-				retryPeriod,
-				FETCH_REMOTE_SERVER_LIST_STALE_PERIOD)
+				controller.signalFetchCommonRemoteServerList)
 		}
 
 		if controller.config.ObfuscatedServerListRootURLs != nil {
@@ -228,9 +227,7 @@ func (controller *Controller) Run(ctx context.Context) {
 			go controller.remoteServerListFetcher(
 				"obfuscated",
 				FetchObfuscatedServerLists,
-				controller.signalFetchObfuscatedServerLists,
-				retryPeriod,
-				FETCH_REMOTE_SERVER_LIST_STALE_PERIOD)
+				controller.signalFetchObfuscatedServerLists)
 		}
 	}
 
@@ -245,10 +242,8 @@ func (controller *Controller) Run(ctx context.Context) {
 	controller.runWaitGroup.Add(1)
 	go controller.runTunnels()
 
-	if *controller.config.EstablishTunnelTimeoutSeconds != 0 {
-		controller.runWaitGroup.Add(1)
-		go controller.establishTunnelWatcher()
-	}
+	controller.runWaitGroup.Add(1)
+	go controller.establishTunnelWatcher()
 
 	if controller.packetTunnelClient != nil {
 		controller.packetTunnelClient.Start()
@@ -313,8 +308,7 @@ func (controller *Controller) SetClientVerificationPayloadForActiveTunnels(clien
 func (controller *Controller) remoteServerListFetcher(
 	name string,
 	fetcher RemoteServerListFetcher,
-	signal <-chan struct{},
-	retryPeriod, stalePeriod time.Duration) {
+	signal <-chan struct{}) {
 
 	defer controller.runWaitGroup.Done()
 
@@ -331,6 +325,10 @@ fetcherLoop:
 
 		// Skip fetch entirely (i.e., send no request at all, even when ETag would save
 		// on response size) when a recent fetch was successful
+
+		stalePeriod := controller.config.clientParameters.Get().Duration(
+			parameters.FetchRemoteServerListStalePeriod)
+
 		if lastFetchTime != 0 &&
 			lastFetchTime.Add(stalePeriod).After(monotime.Now()) {
 			continue
@@ -364,6 +362,9 @@ fetcherLoop:
 
 			NoticeAlert("failed to fetch %s remote server list: %s", name, err)
 
+			retryPeriod := controller.config.clientParameters.Get().Duration(
+				parameters.FetchRemoteServerListRetryPeriod)
+
 			timer := time.NewTimer(retryPeriod)
 			select {
 			case <-timer.C:
@@ -385,17 +386,21 @@ fetcherLoop:
 func (controller *Controller) establishTunnelWatcher() {
 	defer controller.runWaitGroup.Done()
 
-	timer := time.NewTimer(
-		time.Duration(*controller.config.EstablishTunnelTimeoutSeconds) * time.Second)
-	defer timer.Stop()
+	timeout := controller.config.clientParameters.Get().Duration(
+		parameters.EstablishTunnelTimeout)
 
-	select {
-	case <-timer.C:
-		if !controller.hasEstablishedOnce() {
-			NoticeAlert("failed to establish tunnel before timeout")
-			controller.SignalComponentFailure()
+	if timeout > 0 {
+		timer := time.NewTimer(timeout)
+		defer timer.Stop()
+
+		select {
+		case <-timer.C:
+			if !controller.hasEstablishedOnce() {
+				NoticeAlert("failed to establish tunnel before timeout")
+				controller.SignalComponentFailure()
+			}
+		case <-controller.runCtx.Done():
 		}
-	case <-controller.runCtx.Done():
 	}
 
 	NoticeInfo("exiting establish tunnel watcher")
@@ -428,11 +433,16 @@ loop:
 		}
 
 		// Schedule the next connected request and wait.
+		// Note: this duration is not a dynamic ClientParameter as
+		// the daily unique user stats logic specifically requires
+		// a "connected" request no more or less often than every
+		// 24 hours.
 		var duration time.Duration
 		if reported {
-			duration = PSIPHON_API_CONNECTED_REQUEST_PERIOD
+			duration = 24 * time.Hour
 		} else {
-			duration = PSIPHON_API_CONNECTED_REQUEST_RETRY_PERIOD
+			duration = controller.config.clientParameters.Get().Duration(
+				parameters.PsiphonAPIConnectedRequestRetryPeriod)
 		}
 		timer := time.NewTimer(duration)
 		doBreak := false
@@ -504,11 +514,14 @@ downloadLoop:
 			break downloadLoop
 		}
 
+		stalePeriod := controller.config.clientParameters.Get().Duration(
+			parameters.FetchUpgradeStalePeriod)
+
 		// Unless handshake is explicitly advertizing a new version, skip
 		// checking entirely when a recent download was successful.
 		if handshakeVersion == "" &&
 			lastDownloadTime != 0 &&
-			lastDownloadTime.Add(DOWNLOAD_UPGRADE_STALE_PERIOD).After(monotime.Now()) {
+			lastDownloadTime.Add(stalePeriod).After(monotime.Now()) {
 			continue
 		}
 
@@ -541,8 +554,10 @@ downloadLoop:
 
 			NoticeAlert("failed to download upgrade: %s", err)
 
-			timer := time.NewTimer(
-				time.Duration(*controller.config.DownloadUpgradeRetryPeriodSeconds) * time.Second)
+			timeout := controller.config.clientParameters.Get().Duration(
+				parameters.FetchUpgradeRetryPeriod)
+
+			timer := time.NewTimer(timeout)
 			select {
 			case <-timer.C:
 			case <-controller.runCtx.Done():
@@ -803,8 +818,11 @@ func (controller *Controller) classifyImpairedProtocol(failedTunnel *Tunnel) {
 
 	// If the tunnel failed while activating, its establishedTime will be 0.
 
+	duration := controller.config.clientParameters.Get().Duration(
+		parameters.ImpairedProtocolClassificationDuration)
+
 	if failedTunnel.establishedTime == 0 ||
-		failedTunnel.establishedTime.Add(IMPAIRED_PROTOCOL_CLASSIFICATION_DURATION).After(monotime.Now()) {
+		failedTunnel.establishedTime.Add(duration).After(monotime.Now()) {
 		controller.impairedProtocolClassification[failedTunnel.protocol] += 1
 	} else {
 		controller.impairedProtocolClassification[failedTunnel.protocol] = 0
@@ -813,12 +831,11 @@ func (controller *Controller) classifyImpairedProtocol(failedTunnel *Tunnel) {
 	// Reset classification once all known protocols are classified as impaired, as
 	// there is now no way to proceed with only unimpaired protocols. The network
 	// situation (or attack) resulting in classification may not be protocol-specific.
-	//
-	// Note: with controller.config.TunnelProtocol set, this will always reset once
-	// that protocol has reached IMPAIRED_PROTOCOL_CLASSIFICATION_THRESHOLD.
+
 	if CountNonImpairedProtocols(
 		controller.config.EgressRegion,
-		controller.config.TunnelProtocol,
+		controller.config.clientParameters.Get().TunnelProtocols(
+			parameters.LimitTunnelProtocols),
 		controller.getImpairedProtocols()) == 0 {
 
 		controller.impairedProtocolClassification = make(map[string]int)
@@ -830,10 +847,15 @@ func (controller *Controller) classifyImpairedProtocol(failedTunnel *Tunnel) {
 //
 // Concurrency note: only the runTunnels() goroutine may call getImpairedProtocols
 func (controller *Controller) getImpairedProtocols() []string {
+
 	NoticeImpairedProtocolClassification(controller.impairedProtocolClassification)
+
+	threshold := controller.config.clientParameters.Get().Int(
+		parameters.ImpairedProtocolClassificationThreshold)
+
 	impairedProtocols := make([]string, 0)
 	for protocol, count := range controller.impairedProtocolClassification {
-		if count >= IMPAIRED_PROTOCOL_CLASSIFICATION_THRESHOLD {
+		if count >= threshold {
 			impairedProtocols = append(impairedProtocols, protocol)
 		}
 	}
@@ -844,8 +866,13 @@ func (controller *Controller) getImpairedProtocols() []string {
 //
 // Concurrency note: only the runTunnels() goroutine may call isImpairedProtocol
 func (controller *Controller) isImpairedProtocol(protocol string) bool {
+
+	threshold := controller.config.clientParameters.Get().Int(
+		parameters.ImpairedProtocolClassificationThreshold)
+
 	count, ok := controller.impairedProtocolClassification[protocol]
-	return ok && count >= IMPAIRED_PROTOCOL_CLASSIFICATION_THRESHOLD
+
+	return ok && count >= threshold
 }
 
 // SignalSeededNewSLOK implements the TunnelOwner interface. This function
@@ -1045,7 +1072,7 @@ func (controller *Controller) Dial(
 
 	// Perform split tunnel classification when feature is enabled, and if the remote
 	// address is classified as untunneled, dial directly.
-	if !alwaysTunnel && controller.config.SplitTunnelDnsServer != "" {
+	if !alwaysTunnel && controller.config.SplitTunnelDNSServer != "" {
 
 		host, _, err := net.SplitHostPort(remoteAddr)
 		if err != nil {
@@ -1123,14 +1150,68 @@ func (controller *Controller) startEstablishing() {
 	// Note: the establishTunnelWorker that receives the affinity
 	// candidate is solely resonsible for closing
 	// controller.serverAffinityDoneBroadcast.
-	//
-	// Note: if config.EgressRegion or config.TunnelProtocol has changed
-	// since the top server was promoted, the first server may not actually
-	// be the last connected server.
-	// TODO: should not favor the first server in this case
 	controller.serverAffinityDoneBroadcast = make(chan struct{})
 
-	for i := 0; i < controller.config.ConnectionWorkerPoolSize; i++ {
+	controller.establishWaitGroup.Add(1)
+	go controller.launchEstablishing()
+}
+
+func (controller *Controller) launchEstablishing() {
+
+	defer controller.establishWaitGroup.Done()
+
+	// Before starting the establish tunnel workers, get and apply
+	// tactics, launching a tactics request if required.
+	//
+	// Wait only TacticsWaitPeriod for the tactics request to complete (or
+	// fail) before proceeding with tunnel establishment, in case the tactics
+	// request is blocked or takes very long to complete.
+	//
+	// An in-flight tactics request uses meek in round tripper mode, which
+	// uses less resources than meek tunnel relay mode. For this reason, the
+	// tactics request is not counted in concurrentMeekEstablishTunnels.
+	//
+	// TODO: HTTP/2 uses significantly more memory, so perhaps
+	// concurrentMeekEstablishTunnels should be counted in that case.
+	//
+	// Any in-flight tactics request or pending retry will be
+	// canceled when establishment is stopped.
+
+	doTactics := (controller.config.NetworkIDGetter != nil)
+
+	if doTactics {
+
+		timeout := controller.config.clientParameters.Get().Duration(
+			parameters.TacticsWaitPeriod)
+
+		tacticsDone := make(chan struct{})
+		tacticsWaitPeriod := time.NewTimer(timeout)
+		defer tacticsWaitPeriod.Stop()
+
+		controller.establishWaitGroup.Add(1)
+		go controller.getTactics(tacticsDone)
+
+		select {
+		case <-tacticsDone:
+		case <-tacticsWaitPeriod.C:
+		}
+
+		tacticsWaitPeriod.Stop()
+
+		if controller.isStopEstablishing() {
+			// This check isn't strictly required by avoids the
+			// overhead of launching workers if establishment
+			// stopped while awaiting a tactics request.
+			return
+		}
+	}
+
+	// The ConnectionWorkerPoolSize may be set by tactics.
+
+	size := controller.config.clientParameters.Get().Int(
+		parameters.ConnectionWorkerPoolSize)
+
+	for i := 0; i < size; i++ {
 		controller.establishWaitGroup.Add(1)
 		go controller.establishTunnelWorker()
 	}
@@ -1175,6 +1256,189 @@ func (controller *Controller) stopEstablishing() {
 	standardGarbageCollection()
 }
 
+func (controller *Controller) getTactics(done chan struct{}) {
+	defer controller.establishWaitGroup.Done()
+	defer close(done)
+
+	tacticsRecord, err := tactics.UseStoredTactics(
+		GetTacticsStorer(),
+		controller.config.NetworkIDGetter.GetNetworkID())
+	if err != nil {
+		NoticeAlert("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()
+		if err != nil {
+			NoticeAlert("tactics iterator failed: %s", err)
+			return
+		}
+		defer iterator.Close()
+
+		firstIteration := true
+
+		for {
+
+			serverEntry, err := iterator.Next()
+			if err != nil {
+				NoticeAlert("tactics iterator failed: %s", err)
+				return
+			}
+
+			if serverEntry == nil {
+				if firstIteration {
+					NoticeAlert("tactics request skipped: no capable servers")
+				}
+
+				iterator.Reset()
+				continue
+			}
+
+			tacticsRecord, err = controller.doFetchTactics(serverEntry)
+			if err == nil {
+				break
+			}
+
+			NoticeAlert("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.clientParameters.Get()
+			timeout := common.JitterDuration(
+				p.Duration(parameters.TacticsRetryPeriod),
+				p.Float(parameters.TacticsRetryPeriodJitter))
+			p = nil
+
+			tacticsRetryDelay := time.NewTimer(timeout)
+
+			select {
+			case <-controller.establishCtx.Done():
+				return
+			case <-tacticsRetryDelay.C:
+			default:
+			}
+
+			tacticsRetryDelay.Stop()
+		}
+	}
+
+	if tacticsRecord != nil &&
+		common.FlipWeightedCoin(tacticsRecord.Tactics.Probability) {
+
+		err := controller.config.SetClientParameters(
+			tacticsRecord.Tag, true, tacticsRecord.Tactics.Parameters)
+		if err != nil {
+			NoticeAlert("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.
+	aggressiveGarbageCollection()
+	emitMemoryMetrics()
+}
+
+func (controller *Controller) doFetchTactics(
+	serverEntry *protocol.ServerEntry) (*tactics.Record, error) {
+
+	tacticsProtocols := serverEntry.GetSupportedTacticsProtocols()
+
+	index, err := common.MakeSecureRandomInt(len(tacticsProtocols))
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	tacticsProtocol := tacticsProtocols[index]
+
+	meekConfig, err := initMeekConfig(
+		controller.config,
+		serverEntry,
+		tacticsProtocol,
+		"")
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	dialConfig, _, _ := initDialConfig(
+		controller.config, meekConfig)
+
+	// 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.clientParameters.Get().Duration(
+		parameters.TacticsTimeout)
+
+	ctx, cancelFunc := context.WithTimeout(
+		controller.establishCtx,
+		timeout)
+	defer cancelFunc()
+
+	// Limitation: it is assumed that the network ID obtained here is the
+	// one that is active when the tactics request is received by the
+	// server. However, it is remotely possible to switch networks
+	// immediately after invoking the GetNetworkID callback and initiating
+	// the request.
+	//
+	// TODO: ensure that meek in round trip mode will fail the request when
+	// the pre-dial connection is broken.
+
+	networkID := controller.config.NetworkIDGetter.GetNetworkID()
+
+	// 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.
+
+	meekConn, err := DialMeek(ctx, meekConfig, dialConfig)
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+	defer meekConn.Close()
+
+	var apiParams common.APIParameters // *TODO* populate
+
+	tacticsRecord, err := tactics.FetchTactics(
+		ctx,
+		controller.config.clientParameters,
+		GetTacticsStorer(),
+		networkID,
+		apiParams,
+		serverEntry.Region,
+		tacticsProtocol,
+		serverEntry.TacticsRequestPublicKey,
+		serverEntry.TacticsRequestObfuscatedKey,
+		meekConn.RoundTrip)
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	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.
@@ -1210,6 +1474,8 @@ func (controller *Controller) establishCandidateGenerator(impairedProtocols []st
 		close(controller.serverAffinityDoneBroadcast)
 	}
 
+	candidateCount := 0
+
 loop:
 	// Repeat until stopped
 	for i := 0; ; i++ {
@@ -1243,31 +1509,38 @@ loop:
 				continue
 			}
 
+			// Use a prioritized tunnel protocol for the first
+			// PrioritizeTunnelProtocolsCandidateCount candidates.
+			// This facility can be used to favor otherwise slower
+			// protocols.
+
+			prioritizeCandidateCount := controller.config.clientParameters.Get().Int(
+				parameters.PrioritizeTunnelProtocolsCandidateCount)
+			usePriorityProtocol := candidateCount < prioritizeCandidateCount
+
 			// Disable impaired protocols. This is only done for the
-			// first iteration of the ESTABLISH_TUNNEL_WORK_TIME
+			// first iteration of the EstablishTunnelWorkTime
 			// loop since (a) one iteration should be sufficient to
 			// evade the attack; (b) there's a good chance of false
 			// positives (such as short tunnel durations due to network
 			// hopping on a mobile device).
-			// The edited serverEntry is temporary copy which is not
-			// stored or reused.
+
+			var candidateImpairedProtocols []string
 			if i == 0 {
-				serverEntry.DisableImpairedProtocols(impairedProtocols)
-				if len(serverEntry.GetSupportedProtocols(false)) == 0 {
-					// Skip this server entry, as it has no supported
-					// protocols after disabling the impaired ones
-					// TODO: modify ServerEntryIterator to skip these?
-					continue
-				}
+				candidateImpairedProtocols = impairedProtocols
 			}
 
 			// adjustedEstablishStartTime is establishStartTime shifted
 			// to exclude time spent waiting for network connectivity.
 
+			adjustedEstablishStartTime := establishStartTime.Add(networkWaitDuration)
+
 			candidate := &candidateServerEntry{
 				serverEntry:                serverEntry,
 				isServerAffinityCandidate:  isServerAffinityCandidate,
-				adjustedEstablishStartTime: establishStartTime.Add(networkWaitDuration),
+				usePriorityProtocol:        usePriorityProtocol,
+				impairedProtocols:          candidateImpairedProtocols,
+				adjustedEstablishStartTime: adjustedEstablishStartTime,
 			}
 
 			wasServerAffinityCandidate := isServerAffinityCandidate
@@ -1279,13 +1552,18 @@ loop:
 			// TODO: here we could generate multiple candidates from the
 			// server entry when there are many MeekFrontingAddresses.
 
+			candidateCount++
+
 			select {
 			case controller.candidateServerEntries <- candidate:
 			case <-controller.establishCtx.Done():
 				break loop
 			}
 
-			if startTime.Add(ESTABLISH_TUNNEL_WORK_TIME).Before(monotime.Now()) {
+			workTime := controller.config.clientParameters.Get().Duration(
+				parameters.EstablishTunnelWorkTime)
+
+			if startTime.Add(workTime).Before(monotime.Now()) {
 				// Start over, after a brief pause, with a new shuffle of the server
 				// entries, and potentially some newly fetched server entries.
 				break
@@ -1297,28 +1575,43 @@ loop:
 				// candidate has completed (success or failure) or is still working
 				// and the grace period has elapsed.
 
-				timer := time.NewTimer(ESTABLISH_TUNNEL_SERVER_AFFINITY_GRACE_PERIOD)
-				select {
-				case <-timer.C:
-				case <-controller.serverAffinityDoneBroadcast:
-				case <-controller.establishCtx.Done():
+				gracePeriod := controller.config.clientParameters.Get().Duration(
+					parameters.EstablishTunnelServerAffinityGracePeriod)
+
+				if gracePeriod > 0 {
+					timer := time.NewTimer(gracePeriod)
+					select {
+					case <-timer.C:
+					case <-controller.serverAffinityDoneBroadcast:
+					case <-controller.establishCtx.Done():
+						timer.Stop()
+						break loop
+					}
 					timer.Stop()
-					break loop
 				}
-				timer.Stop()
-			} else if controller.config.StaggerConnectionWorkersMilliseconds != 0 {
 
-				// Stagger concurrent connection workers.
+			} else {
+
+				p := controller.config.clientParameters.Get()
+				staggerPeriod := p.Duration(parameters.StaggerConnectionWorkersPeriod)
+				staggerJitter := p.Float(parameters.StaggerConnectionWorkersJitter)
+				p = nil
+
+				if staggerPeriod != 0 {
 
-				timer := time.NewTimer(time.Millisecond * time.Duration(
-					controller.config.StaggerConnectionWorkersMilliseconds))
-				select {
-				case <-timer.C:
-				case <-controller.establishCtx.Done():
+					// Stagger concurrent connection workers.
+
+					timeout := common.JitterDuration(staggerPeriod, staggerJitter)
+
+					timer := time.NewTimer(timeout)
+					select {
+					case <-timer.C:
+					case <-controller.establishCtx.Done():
+						timer.Stop()
+						break loop
+					}
 					timer.Stop()
-					break loop
 				}
-				timer.Stop()
 			}
 		}
 
@@ -1358,8 +1651,14 @@ loop:
 		// network conditions to change. Also allows for fetch remote to complete,
 		// in typical conditions (it isn't strictly necessary to wait for this, there will
 		// be more rounds if required).
-		timer := time.NewTimer(
-			time.Duration(*controller.config.EstablishTunnelPausePeriodSeconds) * time.Second)
+
+		p := controller.config.clientParameters.Get()
+		timeout := common.JitterDuration(
+			p.Duration(parameters.EstablishTunnelPausePeriod),
+			p.Float(parameters.EstablishTunnelPausePeriodJitter))
+		p = nil
+
+		timer := time.NewTimer(timeout)
 		select {
 		case <-timer.C:
 			// Retry iterating
@@ -1394,9 +1693,9 @@ loop:
 		// reclaim as much as possible.
 		aggressiveGarbageCollection()
 
-		// Select the tunnel protocol. Unless config.TunnelProtocol is set, the
-		// selection will be made at random from protocols supported by the
-		// server entry.
+		// Select the tunnel protocol. The selection will be made at random from
+		// protocols supported by the server entry, optionally limited by
+		// LimitTunnelProtocols.
 		//
 		// When limiting concurrent meek connection workers, and at the limit,
 		// do not select meek since otherwise the candidate must be skipped.
@@ -1406,39 +1705,54 @@ loop:
 		// it's probable that the next candidate is not meek. In this case, a
 		// StaggerConnectionWorkersMilliseconds delay may still be incurred.
 
+		limitMeekConnectionWorkers := controller.config.clientParameters.Get().Int(
+			parameters.LimitMeekConnectionWorkers)
+
 		excludeMeek := false
 		controller.concurrentEstablishTunnelsMutex.Lock()
-		if controller.config.LimitMeekConnectionWorkers > 0 &&
+		if limitMeekConnectionWorkers > 0 &&
 			controller.concurrentMeekEstablishTunnels >=
-				controller.config.LimitMeekConnectionWorkers {
+				limitMeekConnectionWorkers {
 			excludeMeek = true
 		}
 		controller.concurrentEstablishTunnelsMutex.Unlock()
 
 		selectedProtocol, err := selectProtocol(
-			controller.config, candidateServerEntry.serverEntry, excludeMeek)
-
-		if err == errProtocolNotSupported {
-			// selectProtocol returns errProtocolNotSupported when excludeMeek
-			// is set and the server entry only supports meek protocols.
+			controller.config,
+			candidateServerEntry.serverEntry,
+			candidateServerEntry.impairedProtocols,
+			excludeMeek,
+			candidateServerEntry.usePriorityProtocol)
+
+		if err == errNoProtocolSupported {
+			// selectProtocol returns errNoProtocolSupported when the server
+			// does not support any protocol that remains after applying the
+			// LimitTunnelProtocols parameter, the impaired protocol filter,
+			// and the excludeMeek flag.
 			// Skip this candidate.
+
+			// Unblock other candidates immediately when
+			// server affinity candidate is skipped.
+			if candidateServerEntry.isServerAffinityCandidate {
+				close(controller.serverAffinityDoneBroadcast)
+			}
+
 			continue
 		}
 
 		var tunnel *Tunnel
 		if err == nil {
 
-			isMeek := protocol.TunnelProtocolUsesMeek(selectedProtocol) ||
-				protocol.TunnelProtocolUsesMeek(selectedProtocol)
+			isMeek := protocol.TunnelProtocolUsesMeek(selectedProtocol)
 
 			controller.concurrentEstablishTunnelsMutex.Lock()
 			if isMeek {
 
 				// Recheck the limit now that we know we're selecting meek and
 				// adjusting concurrentMeekEstablishTunnels.
-				if controller.config.LimitMeekConnectionWorkers > 0 &&
+				if limitMeekConnectionWorkers > 0 &&
 					controller.concurrentMeekEstablishTunnels >=
-						controller.config.LimitMeekConnectionWorkers {
+						limitMeekConnectionWorkers {
 
 					// Skip this candidate.
 					controller.concurrentEstablishTunnelsMutex.Unlock()

+ 28 - 19
psiphon/controller_test.go

@@ -41,6 +41,7 @@ import (
 	"github.com/Psiphon-Inc/goarista/monotime"
 	socks "github.com/Psiphon-Inc/goptlib"
 	"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/protocol"
 	"github.com/elazarl/goproxy"
 )
@@ -458,6 +459,8 @@ func controllerRun(t *testing.T, runConfig *controllerRunConfig) {
 		t.Fatalf("error processing configuration file: %s", err)
 	}
 
+	// The following config values are not client parameters can be set directly.
+
 	if runConfig.clientIsLatestVersion {
 		config.ClientVersion = "999999999"
 	}
@@ -474,37 +477,43 @@ func controllerRun(t *testing.T, runConfig *controllerRunConfig) {
 
 	config.TunnelPoolSize = runConfig.tunnelPoolSize
 
-	if runConfig.disableUntunneledUpgrade {
-		// Disable untunneled upgrade downloader to ensure tunneled case is tested
-		config.UpgradeDownloadClientVersionHeader = ""
-	}
-
 	if runConfig.useUpstreamProxy && runConfig.disruptNetwork {
 		t.Fatalf("cannot use multiple upstream proxies")
 	}
 	if runConfig.disruptNetwork {
-		config.UpstreamProxyUrl = disruptorProxyURL
+		config.UpstreamProxyURL = disruptorProxyURL
 	} else if runConfig.useUpstreamProxy {
-		config.UpstreamProxyUrl = upstreamProxyURL
+		config.UpstreamProxyURL = upstreamProxyURL
 		config.CustomHeaders = upstreamProxyCustomHeaders
 	}
 
+	// The following config values must be applied through client parameters.
+
+	applyParameters := make(map[string]interface{})
+
+	if runConfig.disableUntunneledUpgrade {
+		// Disable untunneled upgrade downloader to ensure tunneled case is tested
+		applyParameters[parameters.UpgradeDownloadClientVersionHeader] = ""
+	}
+
 	if runConfig.transformHostNames {
-		config.TransformHostNames = "always"
+		applyParameters[parameters.TransformHostNameProbability] = 1.0
 	} else {
-		config.TransformHostNames = "never"
+		applyParameters[parameters.TransformHostNameProbability] = 0.0
 	}
 
 	// Override client retry throttle values to speed up automated
 	// tests and ensure tests complete within fixed deadlines.
-	fetchRemoteServerListRetryPeriodSeconds := 0
-	config.FetchRemoteServerListRetryPeriodSeconds = &fetchRemoteServerListRetryPeriodSeconds
-	downloadUpgradeRetryPeriodSeconds := 1
-	config.DownloadUpgradeRetryPeriodSeconds = &downloadUpgradeRetryPeriodSeconds
-	establishTunnelPausePeriodSeconds := 1
-	config.EstablishTunnelPausePeriodSeconds = &establishTunnelPausePeriodSeconds
+	applyParameters[parameters.FetchRemoteServerListRetryPeriod] = "100ms"
+	applyParameters[parameters.FetchUpgradeRetryPeriod] = "100ms"
+	applyParameters[parameters.EstablishTunnelPausePeriod] = "100ms"
 
-	config.TunnelProtocol = runConfig.protocol
+	applyParameters[parameters.LimitTunnelProtocols] = protocol.TunnelProtocols{runConfig.protocol}
+
+	err = config.SetClientParameters("", true, applyParameters)
+	if err != nil {
+		t.Fatalf("SetClientParameters failed: %s", err)
+	}
 
 	os.Remove(config.UpgradeDownloadFilename)
 	os.Remove(config.RemoteServerListDownloadFilename)
@@ -514,7 +523,7 @@ func controllerRun(t *testing.T, runConfig *controllerRunConfig) {
 		t.Fatalf("error initializing datastore: %s", err)
 	}
 
-	serverEntryCount := CountServerEntries("", "")
+	serverEntryCount := CountServerEntries("", nil)
 
 	if runConfig.expectNoServerEntries && serverEntryCount > 0 {
 		// TODO: replace expectNoServerEntries with resetServerEntries
@@ -630,7 +639,7 @@ func controllerRun(t *testing.T, runConfig *controllerRunConfig) {
 				impairedProtocolClassification.classification = make(map[string]int)
 				for k, v := range classification {
 					count := int(v.(float64))
-					if count >= IMPAIRED_PROTOCOL_CLASSIFICATION_THRESHOLD {
+					if count >= config.clientParameters.Get().Int(parameters.ImpairedProtocolClassificationThreshold) {
 						atomic.AddInt32(&impairedProtocolCount, 1)
 					}
 					impairedProtocolClassification.classification[k] = count
@@ -649,7 +658,7 @@ func controllerRun(t *testing.T, runConfig *controllerRunConfig) {
 				impairedProtocolClassification.RUnlock()
 
 				count, ok := classification[serverProtocol]
-				if ok && count >= IMPAIRED_PROTOCOL_CLASSIFICATION_THRESHOLD {
+				if ok && count >= config.clientParameters.Get().Int(parameters.ImpairedProtocolClassificationThreshold) {
 
 					// TODO: Fix this test case. Use of TunnelPoolSize breaks this
 					// case, as many tunnel establishments are occurring in parallel,

+ 130 - 41
psiphon/dataStore.go

@@ -27,12 +27,12 @@ import (
 	"math/rand"
 	"os"
 	"path/filepath"
-	"strings"
 	"sync"
 	"time"
 
 	"github.com/Psiphon-Inc/bolt"
 	"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/protocol"
 )
 
@@ -60,10 +60,15 @@ const (
 	tunnelStatsBucket           = "tunnelStats"
 	remoteServerListStatsBucket = "remoteServerListStats"
 	slokBucket                  = "SLOKs"
-	rankedServerEntryCount      = 100
+	tacticsBucket               = "tactics"
+	speedTestSamplesBucket      = "speedTestSamples"
+
+	rankedServerEntryCount = 100
 )
 
 const (
+	DATA_STORE_FILENAME                     = "psiphon.boltdb"
+	LEGACY_DATA_STORE_FILENAME              = "psiphon.db"
 	DATA_STORE_LAST_CONNECTED_KEY           = "lastConnected"
 	DATA_STORE_LAST_SERVER_ENTRY_FILTER_KEY = "lastServerEntryFilter"
 	PERSISTENT_STAT_TYPE_REMOTE_SERVER_LIST = remoteServerListStatsBucket
@@ -138,6 +143,8 @@ func InitDataStore(config *Config) (err error) {
 				tunnelStatsBucket,
 				remoteServerListStatsBucket,
 				slokBucket,
+				tacticsBucket,
+				speedTestSamplesBucket,
 			}
 			for _, bucket := range requiredBuckets {
 				_, err := tx.CreateBucketIfNotExists([]byte(bucket))
@@ -368,16 +375,11 @@ func PromoteServerEntry(config *Config, ipAddress string) error {
 
 func makeServerEntryFilterValue(config *Config) ([]byte, error) {
 
-	filter, err := json.Marshal(
-		struct {
-			Region   string
-			Protocol string
-		}{config.EgressRegion, config.TunnelProtocol})
-	if err != nil {
-		return nil, common.ContextError(err)
-	}
+	// Currently, only a change of EgressRegion will "break" server affinity.
+	// If the tunnel protocol filter changes, any existing affinity server
+	// either passes the new filter, or it will be skipped anyway.
 
-	return filter, nil
+	return []byte(config.EgressRegion), nil
 }
 
 func hasServerEntryFilterChanged(config *Config) (bool, error) {
@@ -485,8 +487,8 @@ func insertRankedServerEntry(tx *bolt.Tx, serverEntryId string, position int) er
 // ServerEntryIterator is used to iterate over
 // stored server entries in rank order.
 type ServerEntryIterator struct {
-	region                      string
-	protocol                    string
+	config                      *Config
+	supportsTactics             bool
 	shuffleHeadLength           int
 	serverEntryIds              []string
 	serverEntryIndex            int
@@ -524,8 +526,7 @@ func NewServerEntryIterator(config *Config) (bool, *ServerEntryIterator, error)
 	applyServerAffinity := !filterChanged
 
 	iterator := &ServerEntryIterator{
-		region:                      config.EgressRegion,
-		protocol:                    config.TunnelProtocol,
+		config:                      config,
 		shuffleHeadLength:           config.TunnelPoolSize,
 		isTargetServerEntryIterator: false,
 	}
@@ -538,21 +539,42 @@ func NewServerEntryIterator(config *Config) (bool, *ServerEntryIterator, error)
 	return applyServerAffinity, iterator, nil
 }
 
+func NewTacticsServerEntryIterator() (*ServerEntryIterator, error) {
+
+	checkInitDataStore()
+
+	iterator := &ServerEntryIterator{
+		supportsTactics:   true,
+		shuffleHeadLength: 0,
+	}
+
+	err := iterator.Reset()
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	return iterator, nil
+}
+
 // newTargetServerEntryIterator is a helper for initializing the TargetServerEntry case
 func newTargetServerEntryIterator(config *Config) (bool, *ServerEntryIterator, error) {
+
 	serverEntry, err := protocol.DecodeServerEntry(
 		config.TargetServerEntry, common.GetCurrentTimestamp(), protocol.SERVER_ENTRY_SOURCE_TARGET)
 	if err != nil {
 		return false, nil, common.ContextError(err)
 	}
+
 	if config.EgressRegion != "" && serverEntry.Region != config.EgressRegion {
 		return false, nil, common.ContextError(errors.New("TargetServerEntry does not support EgressRegion"))
 	}
-	if config.TunnelProtocol != "" {
-		// Note: same capability/protocol mapping as in StoreServerEntry
-		requiredCapability := strings.TrimSuffix(config.TunnelProtocol, "-OSSH")
-		if !common.Contains(serverEntry.Capabilities, requiredCapability) {
-			return false, nil, common.ContextError(errors.New("TargetServerEntry does not support TunnelProtocol"))
+
+	limitTunnelProtocols := config.clientParameters.Get().TunnelProtocols(parameters.LimitTunnelProtocols)
+	if len(limitTunnelProtocols) > 0 {
+		// At the ServerEntryIterator level, only limitTunnelProtocols is applied;
+		// impairedTunnelProtocols and excludeMeek are handled higher up.
+		if len(serverEntry.GetSupportedProtocols(limitTunnelProtocols, nil, false)) == 0 {
+			return false, nil, common.ContextError(errors.New("TargetServerEntry does not support LimitTunnelProtocols"))
 		}
 	}
 	iterator := &ServerEntryIterator{
@@ -574,8 +596,17 @@ func (iterator *ServerEntryIterator) Reset() error {
 		return nil
 	}
 
-	count := CountServerEntries(iterator.region, iterator.protocol)
-	NoticeCandidateServers(iterator.region, iterator.protocol, count)
+	// For diagnostics, it's useful to count the number of known server
+	// entries that satisfy both the egress region and tunnel protocol
+	// requirements. The tunnel protocol filter is not applied by the iterator
+	// as protocol filtering, including impaire protocol and exclude-meek
+	// logic, is all handled higher up.
+
+	limitTunnelProtocols := iterator.config.clientParameters.Get().TunnelProtocols(
+		parameters.LimitTunnelProtocols)
+
+	count := CountServerEntries(iterator.config.EgressRegion, limitTunnelProtocols)
+	NoticeCandidateServers(iterator.config.EgressRegion, limitTunnelProtocols, count)
 
 	// This query implements the Psiphon server candidate selection
 	// algorithm: the first TunnelPoolSize server candidates are in rank
@@ -644,7 +675,11 @@ func (iterator *ServerEntryIterator) Close() {
 
 // Next returns the next server entry, by rank, for a ServerEntryIterator.
 // Returns nil with no error when there is no next item.
-func (iterator *ServerEntryIterator) Next() (serverEntry *protocol.ServerEntry, err error) {
+func (iterator *ServerEntryIterator) Next() (*protocol.ServerEntry, error) {
+
+	var err error
+	var serverEntry *protocol.ServerEntry
+
 	defer func() {
 		if err != nil {
 			iterator.Close()
@@ -693,7 +728,6 @@ func (iterator *ServerEntryIterator) Next() (serverEntry *protocol.ServerEntry,
 			continue
 		}
 
-		serverEntry = new(protocol.ServerEntry)
 		err = json.Unmarshal(data, serverEntry)
 		if err != nil {
 			// In case of data corruption or a bug causing this condition,
@@ -703,8 +737,8 @@ func (iterator *ServerEntryIterator) Next() (serverEntry *protocol.ServerEntry,
 		}
 
 		// Check filter requirements
-		if (iterator.region == "" || serverEntry.Region == iterator.region) &&
-			(iterator.protocol == "" || serverEntry.SupportsProtocol(iterator.protocol)) {
+		if (iterator.config.EgressRegion == "" || serverEntry.Region == iterator.config.EgressRegion) &&
+			(!iterator.supportsTactics || len(serverEntry.GetSupportedTacticsProtocols()) > 0) {
 
 			break
 		}
@@ -754,14 +788,17 @@ func scanServerEntries(scanner func(*protocol.ServerEntry)) error {
 }
 
 // CountServerEntries returns a count of stored servers for the
-// specified region and protocol.
-func CountServerEntries(region, tunnelProtocol string) int {
+// specified region and tunnel protocols.
+func CountServerEntries(region string, tunnelProtocols []string) int {
 	checkInitDataStore()
 
 	count := 0
 	err := scanServerEntries(func(serverEntry *protocol.ServerEntry) {
 		if (region == "" || serverEntry.Region == region) &&
-			(tunnelProtocol == "" || serverEntry.SupportsProtocol(tunnelProtocol)) {
+			(len(tunnelProtocols) == 0 ||
+				// When CountServerEntries is called only limitTunnelProtocols is known;
+				// impairedTunnelProtocols and excludeMeek may not apply.
+				len(serverEntry.GetSupportedProtocols(tunnelProtocols, nil, false)) > 0) {
 			count += 1
 		}
 	})
@@ -778,8 +815,8 @@ func CountServerEntries(region, tunnelProtocol string) int {
 // protocols supported by stored server entries, excluding the
 // specified impaired protocols.
 func CountNonImpairedProtocols(
-	region, tunnelProtocol string,
-	impairedProtocols []string) int {
+	region string,
+	limitTunnelProtocols, impairedProtocols []string) int {
 
 	checkInitDataStore()
 
@@ -787,15 +824,10 @@ func CountNonImpairedProtocols(
 
 	err := scanServerEntries(func(serverEntry *protocol.ServerEntry) {
 		if region == "" || serverEntry.Region == region {
-			if tunnelProtocol != "" {
-				if serverEntry.SupportsProtocol(tunnelProtocol) {
-					distinctProtocols[tunnelProtocol] = true
-					// Exit early, since only one protocol is enabled
-					return
-				}
-			} else {
-				for _, protocol := range protocol.SupportedTunnelProtocols {
-					if serverEntry.SupportsProtocol(protocol) {
+			for _, protocol := range protocol.SupportedTunnelProtocols {
+				if serverEntry.SupportsProtocol(protocol) {
+					if len(limitTunnelProtocols) == 0 ||
+						common.Contains(limitTunnelProtocols, protocol) {
 						distinctProtocols[protocol] = true
 					}
 				}
@@ -816,7 +848,7 @@ func CountNonImpairedProtocols(
 }
 
 // ReportAvailableRegions prints a notice with the available egress regions.
-// Note that this report ignores config.TunnelProtocol.
+// Note that this report ignores LimitTunnelProtocols.
 func ReportAvailableRegions() {
 	checkInitDataStore()
 
@@ -1307,3 +1339,60 @@ func GetSLOK(id []byte) (key []byte, err error) {
 
 	return key, nil
 }
+
+// TacticsStorer implements tactics.Storer.
+type TacticsStorer struct {
+}
+
+func (t *TacticsStorer) SetTacticsRecord(networkID string, record []byte) error {
+	return setBucketValue([]byte(tacticsBucket), []byte(networkID), record)
+}
+
+func (t *TacticsStorer) GetTacticsRecord(networkID string) ([]byte, error) {
+	return getBucketValue([]byte(tacticsBucket), []byte(networkID))
+}
+
+func (t *TacticsStorer) SetSpeedTestSamplesRecord(networkID string, record []byte) error {
+	return setBucketValue([]byte(speedTestSamplesBucket), []byte(networkID), record)
+}
+
+func (t *TacticsStorer) GetSpeedTestSamplesRecord(networkID string) ([]byte, error) {
+	return getBucketValue([]byte(speedTestSamplesBucket), []byte(networkID))
+}
+
+// GetTacticsStorer creates a TacticsStorer.
+func GetTacticsStorer() *TacticsStorer {
+	return &TacticsStorer{}
+}
+
+func setBucketValue(bucket, key, value []byte) error {
+	checkInitDataStore()
+
+	err := singleton.db.Update(func(tx *bolt.Tx) error {
+		bucket := tx.Bucket(bucket)
+		err := bucket.Put(key, value)
+		return err
+	})
+
+	if err != nil {
+		return common.ContextError(err)
+	}
+
+	return nil
+}
+
+func getBucketValue(bucket, key []byte) (value []byte, err error) {
+	checkInitDataStore()
+
+	err = singleton.db.View(func(tx *bolt.Tx) error {
+		bucket := tx.Bucket(bucket)
+		value = bucket.Get(key)
+		return nil
+	})
+
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	return value, nil
+}

+ 5 - 2
psiphon/feedback.go

@@ -110,7 +110,7 @@ func SendFeedback(configJson, diagnosticsJson, b64EncodedPublicKey, uploadServer
 	}
 
 	untunneledDialConfig := &DialConfig{
-		UpstreamProxyUrl:              config.UpstreamProxyUrl,
+		UpstreamProxyURL:              config.UpstreamProxyURL,
 		CustomHeaders:                 config.CustomHeaders,
 		DeviceBinder:                  nil,
 		IPv6Synthesizer:               nil,
@@ -140,6 +140,7 @@ func SendFeedback(configJson, diagnosticsJson, b64EncodedPublicKey, uploadServer
 
 	for i := 0; i < FEEDBACK_UPLOAD_MAX_RETRIES; i++ {
 		err = uploadFeedback(
+			config,
 			untunneledDialConfig,
 			secureFeedback,
 			url,
@@ -156,7 +157,8 @@ func SendFeedback(configJson, diagnosticsJson, b64EncodedPublicKey, uploadServer
 }
 
 // Attempt to upload feedback data to server.
-func uploadFeedback(config *DialConfig, feedbackData []byte, url, userAgent string, headerPieces []string) error {
+func uploadFeedback(
+	config *Config, dialConfig *DialConfig, feedbackData []byte, url, userAgent string, headerPieces []string) error {
 
 	ctx, cancelFunc := context.WithTimeout(
 		context.Background(),
@@ -166,6 +168,7 @@ func uploadFeedback(config *DialConfig, feedbackData []byte, url, userAgent stri
 	client, err := MakeUntunneledHTTPClient(
 		ctx,
 		config,
+		dialConfig,
 		nil,
 		false)
 	if err != nil {

+ 10 - 5
psiphon/httpProxy.go

@@ -33,9 +33,9 @@ import (
 	"strconv"
 	"strings"
 	"sync"
-	"time"
 
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters"
 	"github.com/grafov/m3u8"
 )
 
@@ -111,12 +111,17 @@ func NewHttpProxy(
 		return tunneler.DirectDial(addr)
 	}
 
-	responseHeaderTimeout := time.Duration(*config.HttpProxyOriginServerTimeoutSeconds) * time.Second
+	responseHeaderTimeout := config.clientParameters.Get().Duration(
+		parameters.HTTPProxyOriginServerTimeout)
+
+	maxIdleConnsPerHost := config.clientParameters.Get().Int(
+		parameters.HTTPProxyMaxIdleConnectionsPerHost)
+
 	// TODO: could HTTP proxy share a tunneled transport with URL proxy?
 	// For now, keeping them distinct just to be conservative.
 	httpProxyTunneledRelay := &http.Transport{
 		Dial:                  tunneledDialer,
-		MaxIdleConnsPerHost:   HTTP_PROXY_MAX_IDLE_CONNECTIONS_PER_HOST,
+		MaxIdleConnsPerHost:   maxIdleConnsPerHost,
 		ResponseHeaderTimeout: responseHeaderTimeout,
 	}
 
@@ -126,7 +131,7 @@ func NewHttpProxy(
 
 	urlProxyTunneledRelay := &http.Transport{
 		Dial:                  tunneledDialer,
-		MaxIdleConnsPerHost:   HTTP_PROXY_MAX_IDLE_CONNECTIONS_PER_HOST,
+		MaxIdleConnsPerHost:   maxIdleConnsPerHost,
 		ResponseHeaderTimeout: responseHeaderTimeout,
 	}
 	urlProxyTunneledClient := &http.Client{
@@ -140,7 +145,7 @@ func NewHttpProxy(
 
 	urlProxyDirectRelay := &http.Transport{
 		Dial:                  directDialer,
-		MaxIdleConnsPerHost:   HTTP_PROXY_MAX_IDLE_CONNECTIONS_PER_HOST,
+		MaxIdleConnsPerHost:   maxIdleConnsPerHost,
 		ResponseHeaderTimeout: responseHeaderTimeout,
 	}
 	urlProxyDirectClient := &http.Client{

+ 11 - 4
psiphon/interrupt_dials_test.go

@@ -30,6 +30,7 @@ import (
 	"time"
 
 	"github.com/Psiphon-Inc/goarista/monotime"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters"
 )
 
 func TestInterruptDials(t *testing.T) {
@@ -43,30 +44,36 @@ func TestInterruptDials(t *testing.T) {
 	makeDialers["SOCKS4-Proxied"] = func(mockServerAddr string) Dialer {
 		return NewTCPDialer(
 			&DialConfig{
-				UpstreamProxyUrl: "socks4a://" + mockServerAddr,
+				UpstreamProxyURL: "socks4a://" + mockServerAddr,
 			})
 	}
 
 	makeDialers["SOCKS5-Proxied"] = func(mockServerAddr string) Dialer {
 		return NewTCPDialer(
 			&DialConfig{
-				UpstreamProxyUrl: "socks5://" + mockServerAddr,
+				UpstreamProxyURL: "socks5://" + mockServerAddr,
 			})
 	}
 
 	makeDialers["HTTP-CONNECT-Proxied"] = func(mockServerAddr string) Dialer {
 		return NewTCPDialer(
 			&DialConfig{
-				UpstreamProxyUrl: "http://" + mockServerAddr,
+				UpstreamProxyURL: "http://" + mockServerAddr,
 			})
 	}
 
 	// TODO: test upstreamproxy.ProxyAuthTransport
 
+	clientParameters, err := parameters.NewClientParameters(nil)
+	if err != nil {
+		t.Fatalf("NewClientParameters failed: %s", err)
+	}
+
 	makeDialers["TLS"] = func(string) Dialer {
 		return NewCustomTLSDialer(
 			&CustomTLSConfig{
-				Dial: NewTCPDialer(&DialConfig{}),
+				ClientParameters: clientParameters,
+				Dial:             NewTCPDialer(&DialConfig{}),
 			})
 	}
 

+ 310 - 153
psiphon/meekConn.go

@@ -29,6 +29,7 @@ import (
 	"errors"
 	"fmt"
 	"io"
+	"io/ioutil"
 	"net"
 	"net/http"
 	"net/url"
@@ -40,6 +41,7 @@ import (
 	"github.com/Psiphon-Inc/goarista/monotime"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/crypto/nacl/box"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/tls"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/upstreamproxy"
@@ -54,32 +56,16 @@ import (
 // https://bitbucket.org/psiphon/psiphon-circumvention-system/src/default/go/meek-client/meek-client.go
 
 const (
-	MEEK_PROTOCOL_VERSION              = 3
-	MEEK_COOKIE_MAX_PADDING            = 32
-	MAX_SEND_PAYLOAD_LENGTH            = 65536
-	FULL_RECEIVE_BUFFER_LENGTH         = 4194304
-	READ_PAYLOAD_CHUNK_LENGTH          = 65536
-	LIMITED_FULL_RECEIVE_BUFFER_LENGTH = 131072
-	LIMITED_READ_PAYLOAD_CHUNK_LENGTH  = 4096
-	MIN_POLL_INTERVAL                  = 100 * time.Millisecond
-	MIN_POLL_INTERVAL_JITTER           = 0.3
-	MAX_POLL_INTERVAL                  = 5 * time.Second
-	MAX_POLL_INTERVAL_JITTER           = 0.1
-	POLL_INTERVAL_MULTIPLIER           = 1.5
-	POLL_INTERVAL_JITTER               = 0.1
-	MEEK_ROUND_TRIP_RETRY_DEADLINE     = 5 * time.Second
-	MEEK_ROUND_TRIP_RETRY_MIN_DELAY    = 50 * time.Millisecond
-	MEEK_ROUND_TRIP_RETRY_MAX_DELAY    = 1000 * time.Millisecond
-	MEEK_ROUND_TRIP_RETRY_MULTIPLIER   = 2
-	MEEK_ROUND_TRIP_TIMEOUT            = 20 * time.Second
+	MEEK_PROTOCOL_VERSION           = 3
+	MEEK_MAX_REQUEST_PAYLOAD_LENGTH = 65536
 )
 
 // MeekConfig specifies the behavior of a MeekConn
 type MeekConfig struct {
 
-	// LimitBufferSizes indicates whether to use smaller buffers to
-	// conserve memory.
-	LimitBufferSizes bool
+	// ClientParameters is the active set of client parameters to use
+	// for the meek dial.
+	ClientParameters *parameters.ClientParameters
 
 	// DialAddress is the actual network address to dial to establish a
 	// connection to the meek server. This may be either a fronted or
@@ -118,10 +104,16 @@ type MeekConfig struct {
 	// tunnel protocol.
 	ClientTunnelProtocol string
 
+	// RoundTripperOnly sets the MeekConn to operate in round tripper
+	// mode, which is used for untunneled tactics requests. In this
+	// mode, a connection is established to the meek server as usual,
+	// but instead of relaying tunnel traffic, the RoundTrip function
+	// may be used to make requests. In this mode, no relay resources
+	// incuding buffers are allocated.
+	RoundTripperOnly bool
+
 	// The following values are used to create the obfuscated meek cookie.
 
-	PsiphonServerAddress          string
-	SessionID                     string
 	MeekCookieEncryptionPublicKey string
 	MeekObfuscatedKey             string
 }
@@ -138,16 +130,25 @@ type MeekConfig struct {
 // MeekConn also operates in unfronted mode, in which plain HTTP connections are made without routing
 // through a CDN.
 type MeekConn struct {
-	url                     *url.URL
-	additionalHeaders       http.Header
-	cookie                  *http.Cookie
-	cachedTLSDialer         *cachedTLSDialer
-	transport               transporter
-	mutex                   sync.Mutex
-	isClosed                bool
-	runCtx                  context.Context
-	stopRunning             context.CancelFunc
-	relayWaitGroup          *sync.WaitGroup
+	clientParameters  *parameters.ClientParameters
+	url               *url.URL
+	additionalHeaders http.Header
+	cookie            *http.Cookie
+	cachedTLSDialer   *cachedTLSDialer
+	transport         transporter
+	mutex             sync.Mutex
+	isClosed          bool
+	runCtx            context.Context
+	stopRunning       context.CancelFunc
+	relayWaitGroup    *sync.WaitGroup
+
+	// For round tripper mode
+	roundTripperOnly              bool
+	meekCookieEncryptionPublicKey string
+	meekObfuscatedKey             string
+	clientTunnelProtocol          string
+
+	// For relay mode
 	fullReceiveBufferLength int
 	readPayloadChunkLength  int
 	emptyReceiveBuffer      chan *bytes.Buffer
@@ -237,6 +238,7 @@ func DialMeek(
 		scheme = "https"
 
 		tlsConfig := &CustomTLSConfig{
+			ClientParameters:              meekConfig.ClientParameters,
 			DialAddr:                      meekConfig.DialAddress,
 			Dial:                          NewTCPDialer(dialConfig),
 			SNIServerName:                 meekConfig.SNIServerName,
@@ -276,10 +278,10 @@ func DialMeek(
 		// purpose, a special preDialer is configured.
 		//
 		// Only one pre-dial attempt is made; there are no retries. This differs
-		// from roundTrip, which retries and may redial for each retry. Retries
-		// at the pre-dial phase are less useful since there's no active session
-		// to preserve, and establishment will simply try another server. Note
-		// that the underlying TCPDial may still try multiple IP addreses when
+		// from relayRoundTrip, which retries and may redial for each retry.
+		// Retries at the pre-dial phase are less useful since there's no active
+		// session to preserve, and establishment will simply try another server.
+		// Note that the underlying TCPDial may still try multiple IP addreses when
 		// the destination is a domain and ir resolves to multiple IP adresses.
 
 		// The pre-dial is made within the parent dial context, so that DialMeek
@@ -287,8 +289,8 @@ func DialMeek(
 		// request context. Since http.DialTLS doesn't take a context argument
 		// (yet; as of Go 1.9 this issue is still open: https://github.com/golang/go/issues/21526),
 		// cachedTLSDialer is used as a conduit to send the request context.
-		// meekConn.roundTrip sets its request context into cachedTLSDialer, and
-		// cachedTLSDialer.dial uses that context.
+		// meekConn.relayRoundTrip sets its request context into cachedTLSDialer,
+		// and cachedTLSDialer.dial uses that context.
 
 		// As DialAddr is set in the CustomTLSConfig, no address is required here.
 		preConn, err := tlsDialer(ctx, "tcp", "")
@@ -337,11 +339,11 @@ func DialMeek(
 		// http.Transport will put the the HTTP server address in the HTTP
 		// request line. In this one case, we can use an HTTP proxy that does
 		// not offer CONNECT support.
-		if strings.HasPrefix(dialConfig.UpstreamProxyUrl, "http://") &&
+		if strings.HasPrefix(dialConfig.UpstreamProxyURL, "http://") &&
 			(meekConfig.DialAddress == meekConfig.HostHeader ||
 				meekConfig.DialAddress == meekConfig.HostHeader+":80") {
 
-			url, err := url.Parse(dialConfig.UpstreamProxyUrl)
+			url, err := url.Parse(dialConfig.UpstreamProxyURL)
 			if err != nil {
 				return nil, common.ContextError(err)
 			}
@@ -351,7 +353,7 @@ func DialMeek(
 			// passes in (which will be proxy address).
 			copyDialConfig := new(DialConfig)
 			*copyDialConfig = *dialConfig
-			copyDialConfig.UpstreamProxyUrl = ""
+			copyDialConfig.UpstreamProxyURL = ""
 
 			dialer = NewTCPDialer(copyDialConfig)
 		}
@@ -392,11 +394,6 @@ func DialMeek(
 		}
 	}
 
-	cookie, err := makeMeekCookie(meekConfig)
-	if err != nil {
-		return nil, common.ContextError(err)
-	}
-
 	// The main loop of a MeekConn is run in the relay() goroutine.
 	// A MeekConn implements net.Conn concurrency semantics:
 	// "Multiple goroutines may invoke methods on a Conn simultaneously."
@@ -414,39 +411,67 @@ func DialMeek(
 	// Write() calls and relay() are synchronized in a similar way, using a single
 	// sendBuffer.
 	meek = &MeekConn{
-		url:                     url,
-		additionalHeaders:       additionalHeaders,
-		cookie:                  cookie,
-		cachedTLSDialer:         cachedTLSDialer,
-		transport:               transport,
-		isClosed:                false,
-		runCtx:                  runCtx,
-		stopRunning:             stopRunning,
-		relayWaitGroup:          new(sync.WaitGroup),
-		fullReceiveBufferLength: FULL_RECEIVE_BUFFER_LENGTH,
-		readPayloadChunkLength:  READ_PAYLOAD_CHUNK_LENGTH,
-		emptyReceiveBuffer:      make(chan *bytes.Buffer, 1),
-		partialReceiveBuffer:    make(chan *bytes.Buffer, 1),
-		fullReceiveBuffer:       make(chan *bytes.Buffer, 1),
-		emptySendBuffer:         make(chan *bytes.Buffer, 1),
-		partialSendBuffer:       make(chan *bytes.Buffer, 1),
-		fullSendBuffer:          make(chan *bytes.Buffer, 1),
+		clientParameters:  meekConfig.ClientParameters,
+		url:               url,
+		additionalHeaders: additionalHeaders,
+		cachedTLSDialer:   cachedTLSDialer,
+		transport:         transport,
+		isClosed:          false,
+		runCtx:            runCtx,
+		stopRunning:       stopRunning,
+		relayWaitGroup:    new(sync.WaitGroup),
+		roundTripperOnly:  meekConfig.RoundTripperOnly,
 	}
 
 	// stopRunning and cachedTLSDialer will now be closed in meek.Close()
 	cleanupStopRunning = false
 	cleanupCachedTLSDialer = false
 
-	meek.emptyReceiveBuffer <- new(bytes.Buffer)
-	meek.emptySendBuffer <- new(bytes.Buffer)
-	meek.relayWaitGroup.Add(1)
+	// Allocate relay resources, including buffers and running the relay
+	// go routine, only when running in relay mode.
+	if !meek.roundTripperOnly {
 
-	if meekConfig.LimitBufferSizes {
-		meek.fullReceiveBufferLength = LIMITED_FULL_RECEIVE_BUFFER_LENGTH
-		meek.readPayloadChunkLength = LIMITED_READ_PAYLOAD_CHUNK_LENGTH
-	}
+		cookie, err := makeMeekCookie(
+			meek.clientParameters,
+			meekConfig.MeekCookieEncryptionPublicKey,
+			meekConfig.MeekObfuscatedKey,
+			meekConfig.ClientTunnelProtocol,
+			"")
+		if err != nil {
+			return nil, common.ContextError(err)
+		}
+
+		meek.cookie = cookie
+
+		p := meekConfig.ClientParameters.Get()
+		if p.Bool(parameters.MeekLimitBufferSizes) {
+			meek.fullReceiveBufferLength = p.Int(parameters.MeekLimitedFullReceiveBufferLength)
+			meek.readPayloadChunkLength = p.Int(parameters.MeekLimitedReadPayloadChunkLength)
+		} else {
+			meek.fullReceiveBufferLength = p.Int(parameters.MeekFullReceiveBufferLength)
+			meek.readPayloadChunkLength = p.Int(parameters.MeekReadPayloadChunkLength)
+		}
+		p = nil
 
-	go meek.relay()
+		meek.emptyReceiveBuffer = make(chan *bytes.Buffer, 1)
+		meek.partialReceiveBuffer = make(chan *bytes.Buffer, 1)
+		meek.fullReceiveBuffer = make(chan *bytes.Buffer, 1)
+		meek.emptySendBuffer = make(chan *bytes.Buffer, 1)
+		meek.partialSendBuffer = make(chan *bytes.Buffer, 1)
+		meek.fullSendBuffer = make(chan *bytes.Buffer, 1)
+
+		meek.emptyReceiveBuffer <- new(bytes.Buffer)
+		meek.emptySendBuffer <- new(bytes.Buffer)
+
+		meek.relayWaitGroup.Add(1)
+		go meek.relay()
+
+	} else {
+
+		meek.meekCookieEncryptionPublicKey = meekConfig.MeekCookieEncryptionPublicKey
+		meek.meekObfuscatedKey = meekConfig.MeekObfuscatedKey
+		meek.clientTunnelProtocol = meekConfig.ClientTunnelProtocol
+	}
 
 	return meek, nil
 }
@@ -454,7 +479,7 @@ func DialMeek(
 type cachedTLSDialer struct {
 	usedCachedConn int32
 	cachedConn     net.Conn
-	requestContext atomic.Value
+	requestCtx     atomic.Value
 	dialer         Dialer
 }
 
@@ -465,8 +490,8 @@ func newCachedTLSDialer(cachedConn net.Conn, dialer Dialer) *cachedTLSDialer {
 	}
 }
 
-func (c *cachedTLSDialer) setRequestContext(requestContext context.Context) {
-	c.requestContext.Store(requestContext)
+func (c *cachedTLSDialer) setRequestContext(requestCtx context.Context) {
+	c.requestCtx.Store(requestCtx)
 }
 
 func (c *cachedTLSDialer) dial(network, addr string) (net.Conn, error) {
@@ -475,7 +500,7 @@ func (c *cachedTLSDialer) dial(network, addr string) (net.Conn, error) {
 		c.cachedConn = nil
 		return conn, nil
 	}
-	ctx := c.requestContext.Load().(context.Context)
+	ctx := c.requestCtx.Load().(context.Context)
 	if ctx == nil {
 		ctx = context.Background()
 	}
@@ -489,8 +514,8 @@ func (c *cachedTLSDialer) close() {
 	}
 }
 
-// Close terminates the meek connection. Close waits for the relay processing goroutine
-// to stop and releases HTTP transport resources.
+// Close terminates the meek connection. Close waits for the relay goroutine
+// to stop (in relay mode) and releases HTTP transport resources.
 // A mutex is required to support net.Conn concurrency semantics.
 func (meek *MeekConn) Close() (err error) {
 
@@ -521,9 +546,78 @@ func (meek *MeekConn) IsClosed() bool {
 	return isClosed
 }
 
+// RoundTrip makes a request to the meek server and returns the response.
+// A new, obfuscated meek cookie is created for every request. The specified
+// end point is recorded in the cookie and is not exposed as plaintext in the
+// meek traffic. The caller is responsible for obfuscating the request body.
+//
+// RoundTrip is not safe for concurrent use, and Close must not be called
+// concurrently. The caller must ensure onlt one RoundTrip call is active
+// at once and that it completes before calling Close.
+//
+// RoundTrip is only available in round tripper mode.
+func (meek *MeekConn) RoundTrip(
+	ctx context.Context, endPoint string, requestBody []byte) ([]byte, error) {
+
+	if !meek.roundTripperOnly {
+		return nil, common.ContextError(errors.New("operation unsupported"))
+	}
+
+	cookie, err := makeMeekCookie(
+		meek.clientParameters,
+		meek.meekCookieEncryptionPublicKey,
+		meek.meekObfuscatedKey,
+		meek.clientTunnelProtocol,
+		endPoint)
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	// Note:
+	//
+	// - multiple, concurrent RoundTrip calls are unsafe due to the
+	//   meek.cachedTLSDialer.setRequestContext call in newRequest
+	//
+	// - concurrent Close and RoundTrip calls are unsafe as Close
+	//   does not synchronize with RoundTrip before calling
+	//   meek.transport.CloseIdleConnections(), so resources could
+	//   be left open.
+	//
+	// At this time, RoundTrip is used for tactics in Controller and
+	// the concurrency constraints are satisfied.
+
+	request, cancelFunc, err := meek.newRequest(
+		ctx, cookie, bytes.NewReader(requestBody), 0)
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+	defer cancelFunc()
+
+	response, err := meek.transport.RoundTrip(request)
+	if err == nil {
+		defer response.Body.Close()
+		if response.StatusCode != http.StatusOK {
+			err = fmt.Errorf("unexpected response status code: %d", response.StatusCode)
+		}
+	}
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	responseBody, err := ioutil.ReadAll(response.Body)
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	return responseBody, nil
+}
+
 // Read reads data from the connection.
 // net.Conn Deadlines are ignored. net.Conn concurrency semantics are supported.
 func (meek *MeekConn) Read(buffer []byte) (n int, err error) {
+	if meek.roundTripperOnly {
+		return 0, common.ContextError(errors.New("operation unsupported"))
+	}
 	if meek.IsClosed() {
 		return 0, common.ContextError(errors.New("meek connection is closed"))
 	}
@@ -543,6 +637,9 @@ func (meek *MeekConn) Read(buffer []byte) (n int, err error) {
 // Write writes data to the connection.
 // net.Conn Deadlines are ignored. net.Conn concurrency semantics are supported.
 func (meek *MeekConn) Write(buffer []byte) (n int, err error) {
+	if meek.roundTripperOnly {
+		return 0, common.ContextError(errors.New("operation unsupported"))
+	}
 	if meek.IsClosed() {
 		return 0, common.ContextError(errors.New("meek connection is closed"))
 	}
@@ -557,7 +654,7 @@ func (meek *MeekConn) Write(buffer []byte) (n int, err error) {
 		case <-meek.runCtx.Done():
 			return 0, common.ContextError(errors.New("meek connection has closed"))
 		}
-		writeLen := MAX_SEND_PAYLOAD_LENGTH - sendBuffer.Len()
+		writeLen := MEEK_MAX_REQUEST_PAYLOAD_LENGTH - sendBuffer.Len()
 		if writeLen > 0 {
 			if writeLen > len(buffer) {
 				writeLen = len(buffer)
@@ -610,7 +707,7 @@ func (meek *MeekConn) replaceSendBuffer(sendBuffer *bytes.Buffer) {
 	switch {
 	case sendBuffer.Len() == 0:
 		meek.emptySendBuffer <- sendBuffer
-	case sendBuffer.Len() >= MAX_SEND_PAYLOAD_LENGTH:
+	case sendBuffer.Len() >= MEEK_MAX_REQUEST_PAYLOAD_LENGTH:
 		meek.fullSendBuffer <- sendBuffer
 	default:
 		meek.partialSendBuffer <- sendBuffer
@@ -626,9 +723,11 @@ func (meek *MeekConn) relay() {
 	// (using goroutines) since Close() will wait on this WaitGroup.
 	defer meek.relayWaitGroup.Done()
 
+	p := meek.clientParameters.Get()
 	interval := common.JitterDuration(
-		MIN_POLL_INTERVAL,
-		MIN_POLL_INTERVAL_JITTER)
+		p.Duration(parameters.MeekMinPollInterval),
+		p.Float(parameters.MeekMinPollIntervalJitter))
+	p = nil
 
 	timeout := time.NewTimer(interval)
 	defer timeout.Stop()
@@ -659,21 +758,22 @@ func (meek *MeekConn) relay() {
 			sendPayloadSize = sendBuffer.Len()
 		}
 
-		// roundTrip will replace sendBuffer (by calling replaceSendBuffer). This is
-		// a compromise to conserve memory. Using a second buffer here, we could copy
-		// sendBuffer and immediately replace it, unblocking meekConn.Write() and
+		// relayRoundTrip will replace sendBuffer (by calling replaceSendBuffer). This
+		// is a compromise to conserve memory. Using a second buffer here, we could
+		// copy sendBuffer and immediately replace it, unblocking meekConn.Write() and
 		// allowing more upstream payload to immediately enqueue. Instead, the request
 		// payload is read directly from sendBuffer, including retries. Only once the
 		// server has acknowledged the request payload is sendBuffer replaced. This
 		// still allows meekConn.Write() to unblock before the round trip response is
 		// read.
 
-		receivedPayloadSize, err := meek.roundTrip(sendBuffer)
+		receivedPayloadSize, err := meek.relayRoundTrip(sendBuffer)
 
 		if err != nil {
 			select {
 			case <-meek.runCtx.Done():
-				// In this case, meek.roundTrip encountered Done(). Exit without logging error.
+				// In this case, meek.relayRoundTrip encountered Done(). Exit without
+				// logging error.
 				return
 			default:
 			}
@@ -688,6 +788,8 @@ func (meek *MeekConn) relay() {
 		// flips are used to avoid trivial, static traffic
 		// timing patterns.
 
+		p := meek.clientParameters.Get()
+
 		if receivedPayloadSize > 0 || sendPayloadSize > 0 {
 
 			interval = 0
@@ -695,27 +797,31 @@ func (meek *MeekConn) relay() {
 		} else if interval == 0 {
 
 			interval = common.JitterDuration(
-				MIN_POLL_INTERVAL,
-				MIN_POLL_INTERVAL_JITTER)
+				p.Duration(parameters.MeekMinPollInterval),
+				p.Float(parameters.MeekMinPollIntervalJitter))
 
 		} else {
 
-			if common.FlipCoin() {
-				interval = common.JitterDuration(
-					interval,
-					POLL_INTERVAL_JITTER)
-			} else {
-				interval = common.JitterDuration(
-					time.Duration(float64(interval)*POLL_INTERVAL_MULTIPLIER),
-					POLL_INTERVAL_JITTER)
+			if p.WeightedCoinFlip(parameters.MeekApplyPollIntervalMultiplierProbability) {
+
+				interval =
+					time.Duration(float64(interval) *
+						p.Float(parameters.MeekPollIntervalMultiplier))
 			}
 
-			if interval >= MAX_POLL_INTERVAL {
+			interval = common.JitterDuration(
+				interval,
+				p.Float(parameters.MeekPollIntervalJitter))
+
+			if interval >= p.Duration(parameters.MeekMaxPollInterval) {
+
 				interval = common.JitterDuration(
-					MAX_POLL_INTERVAL,
-					MAX_POLL_INTERVAL_JITTER)
+					p.Duration(parameters.MeekMaxPollInterval),
+					p.Float(parameters.MeekMaxPollIntervalJitter))
 			}
 		}
+
+		p = nil
 	}
 }
 
@@ -763,8 +869,64 @@ func (r *readCloseSignaller) AwaitClosed() bool {
 	return false
 }
 
-// roundTrip configures and makes the actual HTTP POST request
-func (meek *MeekConn) roundTrip(sendBuffer *bytes.Buffer) (int64, error) {
+// newRequest performs common request setup for both relay and round
+// tripper modes.
+//
+// newRequest is not safe for concurrent calls due to its use of
+// cachedTLSDialer.setRequestContext.
+//
+// The caller must call the returned cancelFunc.
+func (meek *MeekConn) newRequest(
+	ctx context.Context,
+	cookie *http.Cookie,
+	body io.Reader,
+	contentLength int) (*http.Request, context.CancelFunc, error) {
+
+	var requestCtx context.Context
+	var cancelFunc context.CancelFunc
+
+	if ctx != nil {
+		requestCtx, cancelFunc = context.WithCancel(ctx)
+	} else {
+		// - meek.stopRunning() will abort a round trip in flight
+		// - round trip will abort if it exceeds timeout
+		requestCtx, cancelFunc = context.WithTimeout(
+			meek.runCtx,
+			meek.clientParameters.Get().Duration(parameters.MeekRoundTripTimeout))
+	}
+
+	// Ensure TLS dials are made within the current request context.
+	if meek.cachedTLSDialer != nil {
+		meek.cachedTLSDialer.setRequestContext(requestCtx)
+	}
+
+	request, err := http.NewRequest("POST", meek.url.String(), body)
+	if err != nil {
+		return nil, cancelFunc, common.ContextError(err)
+	}
+
+	request = request.WithContext(requestCtx)
+
+	// Content-Length may not be be set automatically due to the
+	// underlying type of requestBody.
+	if contentLength > 0 {
+		request.ContentLength = int64(contentLength)
+	}
+
+	meek.addAdditionalHeaders(request)
+
+	request.Header.Set("Content-Type", "application/octet-stream")
+
+	if cookie == nil {
+		cookie = meek.cookie
+	}
+	request.AddCookie(cookie)
+
+	return request, cancelFunc, nil
+}
+
+// relayRoundTrip configures and makes the actual HTTP POST request
+func (meek *MeekConn) relayRoundTrip(sendBuffer *bytes.Buffer) (int64, error) {
 
 	// Retries are made when the round trip fails. This adds resiliency
 	// to connection interruption and intermittent failures.
@@ -808,9 +970,16 @@ func (meek *MeekConn) roundTrip(sendBuffer *bytes.Buffer) (int64, error) {
 	}()
 
 	retries := uint(0)
-	retryDeadline := monotime.Now().Add(MEEK_ROUND_TRIP_RETRY_DEADLINE)
-	retryDelay := MEEK_ROUND_TRIP_RETRY_MIN_DELAY
+
+	p := meek.clientParameters.Get()
+	retryDeadline := monotime.Now().Add(p.Duration(parameters.MeekRoundTripRetryDeadline))
+	retryDelay := p.Duration(parameters.MeekRoundTripRetryMinDelay)
+	retryMaxDelay := p.Duration(parameters.MeekRoundTripRetryMaxDelay)
+	retryMultiplier := p.Float(parameters.MeekRoundTripRetryMultiplier)
+	p = nil
+
 	serverAcknowledgedRequestPayload := false
+
 	receivedPayloadSize := int64(0)
 
 	for try := 0; ; try++ {
@@ -834,38 +1003,16 @@ func (meek *MeekConn) roundTrip(sendBuffer *bytes.Buffer) (int64, error) {
 			contentLength = sendBuffer.Len()
 		}
 
-		var request *http.Request
-		request, err := http.NewRequest("POST", meek.url.String(), requestBody)
+		request, cancelFunc, err := meek.newRequest(
+			nil,
+			nil,
+			requestBody,
+			contentLength)
 		if err != nil {
 			// Don't retry when can't initialize a Request
 			return 0, common.ContextError(err)
 		}
 
-		// Content-Length won't be set automatically due to the underlying
-		// type of requestBody.
-		if contentLength > 0 {
-			request.ContentLength = int64(contentLength)
-		}
-
-		// - meek.stopRunning() will abort a round trip in flight
-		// - round trip will abort if it exceeds MEEK_ROUND_TRIP_TIMEOUT
-		requestContext, cancelFunc := context.WithTimeout(
-			meek.runCtx,
-			MEEK_ROUND_TRIP_TIMEOUT)
-		defer cancelFunc()
-
-		// Ensure TLS dials are made within the current request context.
-		if meek.cachedTLSDialer != nil {
-			meek.cachedTLSDialer.setRequestContext(requestContext)
-		}
-
-		request = request.WithContext(requestContext)
-
-		meek.addAdditionalHeaders(request)
-
-		request.Header.Set("Content-Type", "application/octet-stream")
-		request.AddCookie(meek.cookie)
-
 		expectedStatusCode := http.StatusOK
 
 		// When retrying, add a Range header to indicate how much
@@ -987,9 +1134,10 @@ func (meek *MeekConn) roundTrip(sendBuffer *bytes.Buffer) (int64, error) {
 		// Increase the next delay, to back off and avoid excessive
 		// activity in conditions such as no network connectivity.
 
-		retryDelay *= MEEK_ROUND_TRIP_RETRY_MULTIPLIER
-		if retryDelay >= MEEK_ROUND_TRIP_RETRY_MAX_DELAY {
-			retryDelay = MEEK_ROUND_TRIP_RETRY_MAX_DELAY
+		retryDelay = time.Duration(
+			float64(retryDelay) * retryMultiplier)
+		if retryDelay >= retryMaxDelay {
+			retryDelay = retryMaxDelay
 		}
 	}
 
@@ -1052,27 +1200,34 @@ func (meek *MeekConn) readPayload(
 	return totalSize, nil
 }
 
-// makeCookie creates the cookie to be sent with initial meek HTTP request.
-// The purpose of the cookie is to send the following to the server:
-//   ServerAddress -- the Psiphon Server address the meek server should relay to
-//   SessionID -- the Psiphon session ID (used by meek server to relay geolocation
-//     information obtained from the CDN through to the Psiphon Server)
-//   MeekProtocolVersion -- tells the meek server that this client understands
-//     the latest protocol.
-// The server will create a session using these values and send the session ID
-// back to the client via Set-Cookie header. Client must use that value with
-// all consequent HTTP requests
-// In unfronted meek mode, the cookie is visible over the adversary network, so the
+// makeCookie creates the cookie to be sent with initial meek HTTP request. The cookies
+// contains obfuscated metadata, including meek version and other protocol information.
+//
+// In round tripper mode, the cookie contains the destination endpoint for the round
+// trip request.
+//
+// In relay mode, the server will create a session using these values and send the session ID
+// back to the client via Set-Cookie header. The client must use that value with
+// all consequent HTTP requests.
+//
+// In plain HTTP meek protocols, the cookie is visible over the adversary network, so the
 // cookie is encrypted and obfuscated.
-func makeMeekCookie(meekConfig *MeekConfig) (cookie *http.Cookie, err error) {
+//
+// Obsolete meek cookie fields used by the legacy server stack are no longer sent. These
+// include ServerAddress and SessionID.
+func makeMeekCookie(
+	clientParameters *parameters.ClientParameters,
+	meekCookieEncryptionPublicKey string,
+	meekObfuscatedKey string,
+	clientTunnelProtocol string,
+	endPoint string,
+
+) (cookie *http.Cookie, err error) {
 
-	// Make the JSON data
-	serverAddress := meekConfig.PsiphonServerAddress
 	cookieData := &protocol.MeekCookieData{
-		ServerAddress:        serverAddress,
-		SessionID:            meekConfig.SessionID,
 		MeekProtocolVersion:  MEEK_PROTOCOL_VERSION,
-		ClientTunnelProtocol: meekConfig.ClientTunnelProtocol,
+		ClientTunnelProtocol: clientTunnelProtocol,
+		EndPoint:             endPoint,
 	}
 	serializedCookie, err := json.Marshal(cookieData)
 	if err != nil {
@@ -1088,7 +1243,7 @@ func makeMeekCookie(meekConfig *MeekConfig) (cookie *http.Cookie, err error) {
 	// different messages if the messages are sent to two different public keys."
 	var nonce [24]byte
 	var publicKey [32]byte
-	decodedPublicKey, err := base64.StdEncoding.DecodeString(meekConfig.MeekCookieEncryptionPublicKey)
+	decodedPublicKey, err := base64.StdEncoding.DecodeString(meekCookieEncryptionPublicKey)
 	if err != nil {
 		return nil, common.ContextError(err)
 	}
@@ -1104,7 +1259,9 @@ func makeMeekCookie(meekConfig *MeekConfig) (cookie *http.Cookie, err error) {
 
 	// Obfuscate the encrypted data
 	obfuscator, err := common.NewClientObfuscator(
-		&common.ObfuscatorConfig{Keyword: meekConfig.MeekObfuscatedKey, MaxPadding: MEEK_COOKIE_MAX_PADDING})
+		&common.ObfuscatorConfig{
+			Keyword:    meekObfuscatedKey,
+			MaxPadding: clientParameters.Get().Int(parameters.MeekCookieMaxPadding)})
 	if err != nil {
 		return nil, common.ContextError(err)
 	}

+ 12 - 5
psiphon/net.go

@@ -43,7 +43,7 @@ const DNS_PORT = 53
 // of a Psiphon dialer (TCPDial, MeekDial, etc.)
 type DialConfig struct {
 
-	// UpstreamProxyUrl specifies a proxy to connect through.
+	// UpstreamProxyURL specifies a proxy to connect through.
 	// E.g., "http://proxyhost:8080"
 	//       "socks5://user:password@proxyhost:1080"
 	//       "socks4a://proxyhost:1080"
@@ -52,11 +52,11 @@ type DialConfig struct {
 	// Certain tunnel protocols require HTTP CONNECT support
 	// when a HTTP proxy is specified. If CONNECT is not
 	// supported, those protocols will not connect.
-	UpstreamProxyUrl string
+	UpstreamProxyURL string
 
 	// CustomHeaders is a set of additional arbitrary HTTP headers that are
 	// added to all plaintext HTTP requests and requests made through an HTTP
-	// upstream proxy when specified by UpstreamProxyUrl.
+	// upstream proxy when specified by UpstreamProxyURL.
 	CustomHeaders http.Header
 
 	// BindToDevice parameters are used to exclude connections and
@@ -119,6 +119,11 @@ type IPv6Synthesizer interface {
 	IPv6Synthesize(IPv4Addr string) string
 }
 
+// NetworkIDGetter defines the interface to the external GetNetworkID provider
+type NetworkIDGetter interface {
+	GetNetworkID() string
+}
+
 // Dialer is a custom network dialer.
 type Dialer func(context.Context, string, string) (net.Conn, error)
 
@@ -215,6 +220,7 @@ func ResolveIP(host string, conn net.Conn) (addrs []net.IP, ttls []time.Duration
 // for applying the context to requests made with the returned http.Client.
 func MakeUntunneledHTTPClient(
 	ctx context.Context,
+	config *Config,
 	untunneledDialConfig *DialConfig,
 	verifyLegacyCertificate *x509.Certificate,
 	skipVerify bool) (*http.Client, error) {
@@ -229,7 +235,8 @@ func MakeUntunneledHTTPClient(
 		// Note: when verifyLegacyCertificate is not nil, some
 		// of the other CustomTLSConfig is overridden.
 		&CustomTLSConfig{
-			Dial: dialer,
+			ClientParameters: config.clientParameters,
+			Dial:             dialer,
 			VerifyLegacyCertificate:       verifyLegacyCertificate,
 			UseDialAddrSNI:                true,
 			SNIServerName:                 "",
@@ -321,7 +328,7 @@ func MakeDownloadHTTPClient(
 	} else {
 
 		httpClient, err = MakeUntunneledHTTPClient(
-			ctx, untunneledDialConfig, nil, skipVerify)
+			ctx, config, untunneledDialConfig, nil, skipVerify)
 		if err != nil {
 			return nil, common.ContextError(err)
 		}

+ 3 - 3
psiphon/notice.go

@@ -372,12 +372,12 @@ func NoticeUserLog(message string) {
 		"message", message)
 }
 
-// NoticeCandidateServers is how many possible servers are available for the selected region and protocol
-func NoticeCandidateServers(region, protocol string, count int) {
+// NoticeCandidateServers is how many possible servers are available for the selected region and protocols
+func NoticeCandidateServers(region string, protocols []string, count int) {
 	singletonNoticeLogger.outputNotice(
 		"CandidateServers", noticeIsDiagnostic,
 		"region", region,
-		"protocol", protocol,
+		"protocols", protocols,
 		"count", count)
 }
 

+ 27 - 14
psiphon/remoteServerList.go

@@ -29,6 +29,7 @@ import (
 
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/osl"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
 )
 
@@ -51,13 +52,20 @@ func FetchCommonRemoteServerList(
 
 	NoticeInfo("fetching common remote server list")
 
-	downloadURL, canonicalURL, skipVerify := selectDownloadURL(attempt, config.RemoteServerListURLs)
+	p := config.clientParameters.Get()
+	publicKey := p.String(parameters.RemoteServerListSignaturePublicKey)
+	urls := p.DownloadURLs(parameters.RemoteServerListURLs)
+	downloadTimeout := p.Duration(parameters.FetchRemoteServerListTimeout)
+	p = nil
+
+	downloadURL, canonicalURL, skipVerify := urls.Select(attempt)
 
 	newETag, err := downloadRemoteServerListFile(
 		ctx,
 		config,
 		tunnel,
 		untunneledDialConfig,
+		downloadTimeout,
 		downloadURL,
 		canonicalURL,
 		skipVerify,
@@ -80,8 +88,7 @@ func FetchCommonRemoteServerList(
 	defer file.Close()
 
 	serverListPayloadReader, err := common.NewAuthenticatedDataPackageReader(
-		file,
-		config.RemoteServerListSignaturePublicKey)
+		file, publicKey)
 	if err != nil {
 		return fmt.Errorf("failed to read remote server list: %s", common.ContextError(err))
 	}
@@ -127,13 +134,19 @@ func FetchObfuscatedServerLists(
 
 	NoticeInfo("fetching obfuscated remote server lists")
 
-	downloadFilename := osl.GetOSLRegistryFilename(config.ObfuscatedServerListDownloadDirectory)
-	cachedFilename := downloadFilename + ".cached"
+	p := config.clientParameters.Get()
+	publicKey := p.String(parameters.RemoteServerListSignaturePublicKey)
+	urls := p.DownloadURLs(parameters.ObfuscatedServerListRootURLs)
+	downloadTimeout := p.Duration(parameters.FetchRemoteServerListTimeout)
+	p = nil
 
-	rootURL, canonicalRootURL, skipVerify := selectDownloadURL(attempt, config.ObfuscatedServerListRootURLs)
+	rootURL, canonicalRootURL, skipVerify := urls.Select(attempt)
 	downloadURL := osl.GetOSLRegistryURL(rootURL)
 	canonicalURL := osl.GetOSLRegistryURL(canonicalRootURL)
 
+	downloadFilename := osl.GetOSLRegistryFilename(config.ObfuscatedServerListDownloadDirectory)
+	cachedFilename := downloadFilename + ".cached"
+
 	// If the cached registry is not present, we need to download or resume downloading
 	// the registry, so clear the ETag to ensure that always happens.
 	_, err := os.Stat(cachedFilename)
@@ -159,6 +172,7 @@ func FetchObfuscatedServerLists(
 		config,
 		tunnel,
 		untunneledDialConfig,
+		downloadTimeout,
 		downloadURL,
 		canonicalURL,
 		skipVerify,
@@ -190,7 +204,7 @@ func FetchObfuscatedServerLists(
 
 	registryStreamer, err := osl.NewRegistryStreamer(
 		registryFile,
-		config.RemoteServerListSignaturePublicKey,
+		publicKey,
 		lookupSLOKs)
 	if err != nil {
 		// TODO: delete file? redownload if corrupt?
@@ -235,6 +249,7 @@ func FetchObfuscatedServerLists(
 			config,
 			tunnel,
 			untunneledDialConfig,
+			downloadTimeout,
 			downloadURL,
 			canonicalURL,
 			skipVerify,
@@ -263,7 +278,7 @@ func FetchObfuscatedServerLists(
 			file,
 			oslFileSpec,
 			lookupSLOKs,
-			config.RemoteServerListSignaturePublicKey)
+			publicKey)
 		if err != nil {
 			file.Close()
 			failed = true
@@ -341,6 +356,7 @@ func downloadRemoteServerListFile(
 	config *Config,
 	tunnel *Tunnel,
 	untunneledDialConfig *DialConfig,
+	downloadTimeout time.Duration,
 	sourceURL string,
 	canonicalURL string,
 	skipVerify bool,
@@ -363,12 +379,9 @@ func downloadRemoteServerListFile(
 		return "", nil
 	}
 
-	if *config.FetchRemoteServerListTimeoutSeconds > 0 {
-		var cancelFunc context.CancelFunc
-		ctx, cancelFunc = context.WithTimeout(
-			ctx, time.Duration(*config.FetchRemoteServerListTimeoutSeconds)*time.Second)
-		defer cancelFunc()
-	}
+	var cancelFunc context.CancelFunc
+	ctx, cancelFunc = context.WithTimeout(ctx, downloadTimeout)
+	defer cancelFunc()
 
 	// MakeDownloadHttpClient will select either a tunneled
 	// or untunneled configuration.

+ 1 - 1
psiphon/remoteServerList_test.go

@@ -213,7 +213,7 @@ func testObfuscatedRemoteServerLists(t *testing.T, omitMD5Sums bool) {
 		t.Fatalf("error initializing client datastore: %s", err)
 	}
 
-	if CountServerEntries("", "") > 0 {
+	if CountServerEntries("", nil) > 0 {
 		t.Fatalf("unexpected server entries")
 	}
 

+ 121 - 57
psiphon/server/api.go

@@ -33,6 +33,7 @@ import (
 
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/tactics"
 )
 
 const (
@@ -47,8 +48,6 @@ const (
 
 var CLIENT_VERIFICATION_REQUIRED = false
 
-type requestJSONObject map[string]interface{}
-
 // sshAPIRequestHandler routes Psiphon API requests transported as
 // JSON objects via the SSH request mechanism.
 //
@@ -79,7 +78,7 @@ func sshAPIRequestHandler(
 	//   type map[string]interface{} will unmarshal a base64-encoded string
 	//   to a string, not a decoded []byte, as required.
 
-	var params requestJSONObject
+	var params common.APIParameters
 	err := json.Unmarshal(requestPayload, &params)
 	if err != nil {
 		return nil, common.ContextError(
@@ -103,7 +102,7 @@ func dispatchAPIRequestHandler(
 	geoIPData GeoIPData,
 	authorizedAccessTypes []string,
 	name string,
-	params requestJSONObject) (response []byte, reterr error) {
+	params common.APIParameters) (response []byte, reterr error) {
 
 	// Recover from and log any unexpected panics caused by user input
 	// handling bugs. User inputs should be properly validated; this
@@ -143,7 +142,7 @@ func dispatchAPIRequestHandler(
 		sessionID, err := getStringRequestParam(params, "client_session_id")
 		if err == nil {
 			// Note: follows/duplicates baseRequestParams validation
-			if !isHexDigits(support, sessionID) {
+			if !isHexDigits(support.Config, sessionID) {
 				err = errors.New("invalid param: client_session_id")
 			}
 		}
@@ -177,6 +176,12 @@ func dispatchAPIRequestHandler(
 	return nil, common.ContextError(fmt.Errorf("invalid request name: %s", name))
 }
 
+var handshakeRequestParams = append(
+	[]requestParamSpec{
+		{tactics.STORED_TACTICS_TAG_PARAMETER_NAME, isAnyString, requestParamOptional},
+		{tactics.SPEED_TEST_SAMPLES_PARAMETER_NAME, nil, requestParamOptional | requestParamJSON}},
+	baseRequestParams...)
+
 // handshakeAPIRequestHandler implements the "handshake" API request.
 // Clients make the handshake immediately after establishing a tunnel
 // connection; the response tells the client what homepage to open, what
@@ -185,11 +190,11 @@ func handshakeAPIRequestHandler(
 	support *SupportServices,
 	apiProtocol string,
 	geoIPData GeoIPData,
-	params requestJSONObject) ([]byte, error) {
+	params common.APIParameters) ([]byte, error) {
 
 	// Note: ignoring "known_servers" params
 
-	err := validateRequestParams(support, params, baseRequestParams)
+	err := validateRequestParams(support.Config, params, baseRequestParams)
 	if err != nil {
 		return nil, common.ContextError(err)
 	}
@@ -233,6 +238,36 @@ func handshakeAPIRequestHandler(
 		return nil, common.ContextError(err)
 	}
 
+	tacticsPayload, err := support.TacticsServer.GetTacticsPayload(
+		common.GeoIPData(geoIPData), params)
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	marshaledTacticsPayload, err := json.Marshal(tacticsPayload)
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	// Log a metric when new tactics are issued. Logging here indicates that
+	// the handshake tactics mechansim is active; but logging for every
+	// handshake creates unneccesary log data.
+
+	if len(tacticsPayload.Tactics) > 0 {
+
+		logFields := getRequestLogFields(
+			tactics.TACTICS_METRIC_EVENT_NAME,
+			geoIPData,
+			authorizedAccessTypes,
+			params,
+			handshakeRequestParams)
+
+		logFields[tactics.NEW_TACTICS_TAG_LOG_FIELD_NAME] = tacticsPayload.Tag
+		logFields[tactics.IS_TACTICS_REQUEST_LOG_FIELD_NAME] = false
+
+		log.LogRawFieldsWithTimestamp(logFields)
+	}
+
 	// The log comes _after_ SetClientHandshakeState, in case that call rejects
 	// the state change (for example, if a second handshake is performed)
 	//
@@ -257,6 +292,7 @@ func handshakeAPIRequestHandler(
 		ClientRegion:           geoIPData.Country,
 		ServerTimestamp:        common.GetCurrentTimestamp(),
 		ActiveAuthorizationIDs: activeAuthorizationIDs,
+		TacticsPayload:         marshaledTacticsPayload,
 	}
 
 	responsePayload, err := json.Marshal(handshakeResponse)
@@ -283,9 +319,9 @@ func connectedAPIRequestHandler(
 	support *SupportServices,
 	geoIPData GeoIPData,
 	authorizedAccessTypes []string,
-	params requestJSONObject) ([]byte, error) {
+	params common.APIParameters) ([]byte, error) {
 
-	err := validateRequestParams(support, params, connectedRequestParams)
+	err := validateRequestParams(support.Config, params, connectedRequestParams)
 	if err != nil {
 		return nil, common.ContextError(err)
 	}
@@ -326,9 +362,9 @@ func statusAPIRequestHandler(
 	support *SupportServices,
 	geoIPData GeoIPData,
 	authorizedAccessTypes []string,
-	params requestJSONObject) ([]byte, error) {
+	params common.APIParameters) ([]byte, error) {
 
-	err := validateRequestParams(support, params, statusRequestParams)
+	err := validateRequestParams(support.Config, params, statusRequestParams)
 	if err != nil {
 		return nil, common.ContextError(err)
 	}
@@ -435,9 +471,9 @@ func clientVerificationAPIRequestHandler(
 	support *SupportServices,
 	geoIPData GeoIPData,
 	authorizedAccessTypes []string,
-	params requestJSONObject) ([]byte, error) {
+	params common.APIParameters) ([]byte, error) {
 
-	err := validateRequestParams(support, params, baseRequestParams)
+	err := validateRequestParams(support.Config, params, baseRequestParams)
 	if err != nil {
 		return nil, common.ContextError(err)
 	}
@@ -493,9 +529,30 @@ func clientVerificationAPIRequestHandler(
 	}
 }
 
+func getTacticsAPIParameterValidator(config *Config) common.APIParameterValidator {
+	return func(params common.APIParameters) error {
+		return validateRequestParams(config, params, handshakeRequestParams)
+	}
+}
+
+func getTacticsAPIParameterLogFieldFormatter() common.APIParameterLogFieldFormatter {
+
+	return func(geoIPData common.GeoIPData, params common.APIParameters) common.LogFields {
+
+		logFields := getRequestLogFields(
+			tactics.TACTICS_METRIC_EVENT_NAME,
+			GeoIPData(geoIPData),
+			nil, // authorizedAccessTypes are not known yet
+			params,
+			handshakeRequestParams)
+
+		return common.LogFields(logFields)
+	}
+}
+
 type requestParamSpec struct {
 	name      string
-	validator func(*SupportServices, string) bool
+	validator func(*Config, string) bool
 	flags     uint32
 }
 
@@ -503,6 +560,7 @@ const (
 	requestParamOptional  = 1
 	requestParamNotLogged = 2
 	requestParamArray     = 4
+	requestParamJSON      = 8
 )
 
 // baseRequestParams is the list of required and optional
@@ -534,11 +592,12 @@ var baseRequestParams = []requestParamSpec{
 	{"server_entry_region", isRegionCode, requestParamOptional},
 	{"server_entry_source", isServerEntrySource, requestParamOptional},
 	{"server_entry_timestamp", isISO8601Date, requestParamOptional},
+	{"active_tactics_tag", isAnyString, requestParamOptional},
 }
 
 func validateRequestParams(
-	support *SupportServices,
-	params requestJSONObject,
+	config *Config,
+	params common.APIParameters,
 	expectedParams []requestParamSpec) error {
 
 	for _, expectedParam := range expectedParams {
@@ -551,10 +610,15 @@ func validateRequestParams(
 				fmt.Errorf("missing param: %s", expectedParam.name))
 		}
 		var err error
-		if expectedParam.flags&requestParamArray != 0 {
-			err = validateStringArrayRequestParam(support, expectedParam, value)
-		} else {
-			err = validateStringRequestParam(support, expectedParam, value)
+		switch {
+		case expectedParam.flags&requestParamArray != 0:
+			err = validateStringArrayRequestParam(config, expectedParam, value)
+		case expectedParam.flags&requestParamJSON != 0:
+			// No validation: the JSON already unmarshalled; the parameter
+			// user will validate that the JSON contains the expected
+			// objects/data.
+		default:
+			err = validateStringRequestParam(config, expectedParam, value)
 		}
 		if err != nil {
 			return common.ContextError(err)
@@ -566,12 +630,12 @@ func validateRequestParams(
 
 // copyBaseRequestParams makes a copy of the params which
 // includes only the baseRequestParams.
-func copyBaseRequestParams(params requestJSONObject) requestJSONObject {
+func copyBaseRequestParams(params common.APIParameters) common.APIParameters {
 
 	// Note: not a deep copy; assumes baseRequestParams values
 	// are all scalar types (int, string, etc.)
 
-	paramsCopy := make(requestJSONObject)
+	paramsCopy := make(common.APIParameters)
 	for _, baseParam := range baseRequestParams {
 		value := params[baseParam.name]
 		if value == nil {
@@ -585,7 +649,7 @@ func copyBaseRequestParams(params requestJSONObject) requestJSONObject {
 }
 
 func validateStringRequestParam(
-	support *SupportServices,
+	config *Config,
 	expectedParam requestParamSpec,
 	value interface{}) error {
 
@@ -594,7 +658,7 @@ func validateStringRequestParam(
 		return common.ContextError(
 			fmt.Errorf("unexpected string param type: %s", expectedParam.name))
 	}
-	if !expectedParam.validator(support, strValue) {
+	if !expectedParam.validator(config, strValue) {
 		return common.ContextError(
 			fmt.Errorf("invalid param: %s", expectedParam.name))
 	}
@@ -602,7 +666,7 @@ func validateStringRequestParam(
 }
 
 func validateStringArrayRequestParam(
-	support *SupportServices,
+	config *Config,
 	expectedParam requestParamSpec,
 	value interface{}) error {
 
@@ -612,7 +676,7 @@ func validateStringArrayRequestParam(
 			fmt.Errorf("unexpected string param type: %s", expectedParam.name))
 	}
 	for _, value := range arrayValue {
-		err := validateStringRequestParam(support, expectedParam, value)
+		err := validateStringRequestParam(config, expectedParam, value)
 		if err != nil {
 			return common.ContextError(err)
 		}
@@ -626,7 +690,7 @@ func getRequestLogFields(
 	eventName string,
 	geoIPData GeoIPData,
 	authorizedAccessTypes []string,
-	params requestJSONObject,
+	params common.APIParameters,
 	expectedParams []requestParamSpec) LogFields {
 
 	logFields := make(LogFields)
@@ -713,7 +777,7 @@ func getRequestLogFields(
 	return logFields
 }
 
-func getStringRequestParam(params requestJSONObject, name string) (string, error) {
+func getStringRequestParam(params common.APIParameters, name string) (string, error) {
 	if params[name] == nil {
 		return "", common.ContextError(fmt.Errorf("missing param: %s", name))
 	}
@@ -724,7 +788,7 @@ func getStringRequestParam(params requestJSONObject, name string) (string, error
 	return value, nil
 }
 
-func getInt64RequestParam(params requestJSONObject, name string) (int64, error) {
+func getInt64RequestParam(params common.APIParameters, name string) (int64, error) {
 	if params[name] == nil {
 		return 0, common.ContextError(fmt.Errorf("missing param: %s", name))
 	}
@@ -735,19 +799,19 @@ func getInt64RequestParam(params requestJSONObject, name string) (int64, error)
 	return int64(value), nil
 }
 
-func getJSONObjectRequestParam(params requestJSONObject, name string) (requestJSONObject, error) {
+func getJSONObjectRequestParam(params common.APIParameters, name string) (common.APIParameters, error) {
 	if params[name] == nil {
 		return nil, common.ContextError(fmt.Errorf("missing param: %s", name))
 	}
-	// Note: generic unmarshal of JSON produces map[string]interface{}, not requestJSONObject
+	// Note: generic unmarshal of JSON produces map[string]interface{}, not common.APIParameters
 	value, ok := params[name].(map[string]interface{})
 	if !ok {
 		return nil, common.ContextError(fmt.Errorf("invalid param: %s", name))
 	}
-	return requestJSONObject(value), nil
+	return common.APIParameters(value), nil
 }
 
-func getJSONObjectArrayRequestParam(params requestJSONObject, name string) ([]requestJSONObject, error) {
+func getJSONObjectArrayRequestParam(params common.APIParameters, name string) ([]common.APIParameters, error) {
 	if params[name] == nil {
 		return nil, common.ContextError(fmt.Errorf("missing param: %s", name))
 	}
@@ -756,24 +820,24 @@ func getJSONObjectArrayRequestParam(params requestJSONObject, name string) ([]re
 		return nil, common.ContextError(fmt.Errorf("invalid param: %s", name))
 	}
 
-	result := make([]requestJSONObject, len(value))
+	result := make([]common.APIParameters, len(value))
 	for i, item := range value {
-		// Note: generic unmarshal of JSON produces map[string]interface{}, not requestJSONObject
+		// Note: generic unmarshal of JSON produces map[string]interface{}, not common.APIParameters
 		resultItem, ok := item.(map[string]interface{})
 		if !ok {
 			return nil, common.ContextError(fmt.Errorf("invalid param: %s", name))
 		}
-		result[i] = requestJSONObject(resultItem)
+		result[i] = common.APIParameters(resultItem)
 	}
 
 	return result, nil
 }
 
-func getMapStringInt64RequestParam(params requestJSONObject, name string) (map[string]int64, error) {
+func getMapStringInt64RequestParam(params common.APIParameters, name string) (map[string]int64, error) {
 	if params[name] == nil {
 		return nil, common.ContextError(fmt.Errorf("missing param: %s", name))
 	}
-	// TODO: can't use requestJSONObject type?
+	// TODO: can't use common.APIParameters type?
 	value, ok := params[name].(map[string]interface{})
 	if !ok {
 		return nil, common.ContextError(fmt.Errorf("invalid param: %s", name))
@@ -791,7 +855,7 @@ func getMapStringInt64RequestParam(params requestJSONObject, name string) (map[s
 	return result, nil
 }
 
-func getStringArrayRequestParam(params requestJSONObject, name string) ([]string, error) {
+func getStringArrayRequestParam(params common.APIParameters, name string) ([]string, error) {
 	if params[name] == nil {
 		return nil, common.ContextError(fmt.Errorf("missing param: %s", name))
 	}
@@ -826,7 +890,7 @@ func normalizeClientPlatform(clientPlatform string) string {
 	return CLIENT_PLATFORM_WINDOWS
 }
 
-func isAnyString(support *SupportServices, value string) bool {
+func isAnyString(config *Config, value string) bool {
 	return true
 }
 
@@ -838,51 +902,51 @@ func isMobileClientPlatform(clientPlatform string) bool {
 
 // Input validators follow the legacy validations rules in psi_web.
 
-func isServerSecret(support *SupportServices, value string) bool {
+func isServerSecret(config *Config, value string) bool {
 	return subtle.ConstantTimeCompare(
 		[]byte(value),
-		[]byte(support.Config.WebServerSecret)) == 1
+		[]byte(config.WebServerSecret)) == 1
 }
 
-func isHexDigits(_ *SupportServices, value string) bool {
+func isHexDigits(_ *Config, value string) bool {
 	// Allows both uppercase in addition to lowercase, for legacy support.
 	return -1 == strings.IndexFunc(value, func(c rune) bool {
 		return !unicode.Is(unicode.ASCII_Hex_Digit, c)
 	})
 }
 
-func isDigits(_ *SupportServices, value string) bool {
+func isDigits(_ *Config, value string) bool {
 	return -1 == strings.IndexFunc(value, func(c rune) bool {
 		return c < '0' || c > '9'
 	})
 }
 
-func isIntString(_ *SupportServices, value string) bool {
+func isIntString(_ *Config, value string) bool {
 	_, err := strconv.Atoi(value)
 	return err == nil
 }
 
-func isClientPlatform(_ *SupportServices, value string) bool {
+func isClientPlatform(_ *Config, value string) bool {
 	return -1 == strings.IndexFunc(value, func(c rune) bool {
 		// Note: stricter than psi_web's Python string.whitespace
 		return unicode.Is(unicode.White_Space, c)
 	})
 }
 
-func isRelayProtocol(_ *SupportServices, value string) bool {
+func isRelayProtocol(_ *Config, value string) bool {
 	return common.Contains(protocol.SupportedTunnelProtocols, value)
 }
 
-func isBooleanFlag(_ *SupportServices, value string) bool {
+func isBooleanFlag(_ *Config, value string) bool {
 	return value == "0" || value == "1"
 }
 
-func isUpstreamProxyType(_ *SupportServices, value string) bool {
+func isUpstreamProxyType(_ *Config, value string) bool {
 	value = strings.ToLower(value)
 	return value == "http" || value == "socks5" || value == "socks4a"
 }
 
-func isRegionCode(_ *SupportServices, value string) bool {
+func isRegionCode(_ *Config, value string) bool {
 	if len(value) != 2 {
 		return false
 	}
@@ -891,7 +955,7 @@ func isRegionCode(_ *SupportServices, value string) bool {
 	})
 }
 
-func isDialAddress(_ *SupportServices, value string) bool {
+func isDialAddress(_ *Config, value string) bool {
 	// "<host>:<port>", where <host> is a domain or IP address
 	parts := strings.Split(value, ":")
 	if len(parts) != 2 {
@@ -910,13 +974,13 @@ func isDialAddress(_ *SupportServices, value string) bool {
 	return port > 0 && port < 65536
 }
 
-func isIPAddress(_ *SupportServices, value string) bool {
+func isIPAddress(_ *Config, value string) bool {
 	return net.ParseIP(value) != nil
 }
 
 var isDomainRegex = regexp.MustCompile("[a-zA-Z\\d-]{1,63}$")
 
-func isDomain(_ *SupportServices, value string) bool {
+func isDomain(_ *Config, value string) bool {
 
 	// From: http://stackoverflow.com/questions/2532053/validate-a-hostname-string
 	//
@@ -943,7 +1007,7 @@ func isDomain(_ *SupportServices, value string) bool {
 	return true
 }
 
-func isHostHeader(_ *SupportServices, value string) bool {
+func isHostHeader(_ *Config, value string) bool {
 	// "<host>:<port>", where <host> is a domain or IP address and ":<port>" is optional
 	if strings.Contains(value, ":") {
 		return isDialAddress(nil, value)
@@ -951,17 +1015,17 @@ func isHostHeader(_ *SupportServices, value string) bool {
 	return isIPAddress(nil, value) || isDomain(nil, value)
 }
 
-func isServerEntrySource(_ *SupportServices, value string) bool {
+func isServerEntrySource(_ *Config, value string) bool {
 	return common.Contains(protocol.SupportedServerEntrySources, value)
 }
 
 var isISO8601DateRegex = regexp.MustCompile(
 	"(?P<year>[0-9]{4})-(?P<month>[0-9]{1,2})-(?P<day>[0-9]{1,2})T(?P<hour>[0-9]{2}):(?P<minute>[0-9]{2}):(?P<second>[0-9]{2})(\\.(?P<fraction>[0-9]+))?(?P<timezone>Z|(([-+])([0-9]{2}):([0-9]{2})))")
 
-func isISO8601Date(_ *SupportServices, value string) bool {
+func isISO8601Date(_ *Config, value string) bool {
 	return isISO8601DateRegex.Match([]byte(value))
 }
 
-func isLastConnected(_ *SupportServices, value string) bool {
+func isLastConnected(_ *Config, value string) bool {
 	return value == "None" || value == "Unknown" || isISO8601Date(nil, value)
 }

+ 4 - 0
psiphon/server/config.go

@@ -297,6 +297,10 @@ type Config struct {
 	// available for matching in the TrafficRulesFilter for the client via
 	// AuthorizedAccessTypes. All other authorizations are ignored.
 	AccessControlVerificationKeyRing accesscontrol.VerificationKeyRing
+
+	// TacticsConfigFilename is the path of a file containing a JSON-encoded
+	// tactics server configuration.
+	TacticsConfigFilename string
 }
 
 // RunWebServer indicates whether to run a web server component.

+ 56 - 22
psiphon/server/meek.go

@@ -81,7 +81,7 @@ const (
 )
 
 // MeekServer implements the meek protocol, which tunnels TCP traffic (in the case of Psiphon,
-// Obfusated SSH traffic) over HTTP. Meek may be fronted (through a CDN) or direct and may be
+// Obfuscated SSH traffic) over HTTP. Meek may be fronted (through a CDN) or direct and may be
 // HTTP or HTTPS.
 //
 // Upstream traffic arrives in HTTP request bodies and downstream traffic is sent in response
@@ -261,9 +261,17 @@ func (server *MeekServer) ServeHTTP(responseWriter http.ResponseWriter, request
 		}
 	}
 
-	// Lookup or create a new session for given meek cookie/session ID.
+	// A valid meek cookie indicates which class of request this is:
+	//
+	// 1. A new meek session. Create a new session ID and proceed with
+	// relaying tunnel traffic.
+	//
+	// 2. An existing meek session. Resume relaying tunnel traffic.
+	//
+	// 3. A request to an endpoint. This meek connection is not for relaying
+	// tunnel traffic. Instead, the request is handed off to a custom handler.
 
-	sessionID, session, err := server.getSession(request, meekCookie)
+	sessionID, session, endPoint, clientIP, err := server.getSessionOrEndpoint(request, meekCookie)
 	if err != nil {
 		// Debug since session cookie errors commonly occur during
 		// normal operation.
@@ -272,6 +280,23 @@ func (server *MeekServer) ServeHTTP(responseWriter http.ResponseWriter, request
 		return
 	}
 
+	if endPoint != "" {
+
+		// Endpoint mode. Currently, this means it's handled by the tactics
+		// request handler.
+
+		geoIPData := server.support.GeoIPService.Lookup(clientIP)
+		handled := server.support.TacticsServer.HandleEndPoint(
+			endPoint, common.GeoIPData(geoIPData), responseWriter, request)
+		if !handled {
+			log.WithContextFields(LogFields{"endPoint": endPoint}).Info("unhandled endpoint")
+			server.terminateConnection(responseWriter, request)
+		}
+		return
+	}
+
+	// Tunnel relay mode.
+
 	// Ensure that there's only one concurrent request handler per client
 	// session. Depending on the nature of a network disruption, it can
 	// happen that a client detects a failure and retries while the server
@@ -470,12 +495,13 @@ func checkRangeHeader(request *http.Request) (int, bool) {
 	return position, true
 }
 
-// getSession returns the meek client session corresponding the
-// meek cookie/session ID. If no session is found, the cookie is
-// treated as a meek cookie for a new session and its payload is
-// extracted and used to establish a new session.
-func (server *MeekServer) getSession(
-	request *http.Request, meekCookie *http.Cookie) (string, *meekSession, error) {
+// getSessionOrEndpoint checks if the cookie corresponds to an existing tunnel
+// relay session ID. If no session is found, the cookie must be an obfuscated
+// meek cookie. A new session is created when the meek cookie indicates relay
+// mode; or the endpoint is returned when the meek cookie indicates endpoint
+// mode.
+func (server *MeekServer) getSessionOrEndpoint(
+	request *http.Request, meekCookie *http.Cookie) (string, *meekSession, string, string, error) {
 
 	// Check for an existing session
 
@@ -485,15 +511,7 @@ func (server *MeekServer) getSession(
 	server.sessionsLock.RUnlock()
 	if ok {
 		session.touch()
-		return existingSessionID, session, nil
-	}
-
-	// Don't create new sessions when not establishing. A subsequent SSH handshake
-	// will not succeed, so creating a meek session just wastes resources.
-
-	if server.support.TunnelServer != nil &&
-		!server.support.TunnelServer.GetEstablishTunnels() {
-		return "", nil, common.ContextError(errors.New("not establishing tunnels"))
+		return existingSessionID, session, "", "", nil
 	}
 
 	// TODO: can multiple http client connections using same session cookie
@@ -504,7 +522,7 @@ func (server *MeekServer) getSession(
 
 	payloadJSON, err := getMeekCookiePayload(server.support, meekCookie.Value)
 	if err != nil {
-		return "", nil, common.ContextError(err)
+		return "", nil, "", "", common.ContextError(err)
 	}
 
 	// Note: this meek server ignores legacy values PsiphonClientSessionId
@@ -513,7 +531,7 @@ func (server *MeekServer) getSession(
 
 	err = json.Unmarshal(payloadJSON, &clientSessionData)
 	if err != nil {
-		return "", nil, common.ContextError(err)
+		return "", nil, "", "", common.ContextError(err)
 	}
 
 	// Determine the client remote address, which is used for geolocation
@@ -541,6 +559,22 @@ func (server *MeekServer) getSession(
 		}
 	}
 
+	// Handle endpoints before enforcing the GetEstablishTunnels check.
+	// Currently, endpoints are tactics requests, and we allow these to be
+	// handled by servers which would otherwise reject new tunnels.
+
+	if clientSessionData.EndPoint != "" {
+		return "", nil, clientSessionData.EndPoint, clientIP, nil
+	}
+
+	// Don't create new sessions when not establishing. A subsequent SSH handshake
+	// will not succeed, so creating a meek session just wastes resources.
+
+	if server.support.TunnelServer != nil &&
+		!server.support.TunnelServer.GetEstablishTunnels() {
+		return "", nil, "", "", common.ContextError(errors.New("not establishing tunnels"))
+	}
+
 	// Create a new session
 
 	bufferLength := MEEK_DEFAULT_RESPONSE_BUFFER_LENGTH
@@ -587,7 +621,7 @@ func (server *MeekServer) getSession(
 	if clientSessionData.MeekProtocolVersion >= MEEK_PROTOCOL_VERSION_2 {
 		sessionID, err = makeMeekSessionID()
 		if err != nil {
-			return "", nil, common.ContextError(err)
+			return "", nil, "", "", common.ContextError(err)
 		}
 	}
 
@@ -599,7 +633,7 @@ func (server *MeekServer) getSession(
 	// will close when session.delete calls Close() on the meekConn.
 	server.clientHandler(clientSessionData.ClientTunnelProtocol, session.clientConn)
 
-	return sessionID, session, nil
+	return sessionID, session, "", "", nil
 }
 
 func (server *MeekServer) deleteSession(sessionID string) {

+ 7 - 0
psiphon/server/meek_test.go

@@ -35,6 +35,7 @@ import (
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/crypto/nacl/box"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters"
 )
 
 var KB = 1024
@@ -299,7 +300,13 @@ func TestMeekResiliency(t *testing.T) {
 		DeviceBinder:            new(fileDescriptorInterruptor),
 	}
 
+	clientParameters, err := parameters.NewClientParameters(nil)
+	if err != nil {
+		t.Fatalf("NewClientParameters failed: %s", err)
+	}
+
 	meekConfig := &psiphon.MeekConfig{
+		ClientParameters:              clientParameters,
 		DialAddress:                   serverAddress,
 		UseHTTPS:                      useTLS,
 		UseObfuscatedSessionTickets:   useObfuscatedSessionTickets,

+ 3 - 3
psiphon/server/safetyNet.go

@@ -60,7 +60,7 @@ type jwt struct {
 	payload string
 }
 
-func newJwt(token requestJSONObject) (jwt, error) {
+func newJwt(token common.APIParameters) (jwt, error) {
 	jwtObj := jwt{}
 
 	if token["status"] == nil {
@@ -199,7 +199,7 @@ func (body *jwtBody) verifyJWTBody() (validApkCert, validApkPackageName bool) {
 }
 
 // Form log fields for debugging
-func errorLogFields(err error, params requestJSONObject) LogFields {
+func errorLogFields(err error, params common.APIParameters) LogFields {
 	logFields := LogFields{
 		// Must sanitize string. JSON unmarshalling exceptions
 		// include the value of the field which failed to unmarshal.
@@ -267,7 +267,7 @@ func (l LogFields) addJwtField(field string, input interface{}) {
 }
 
 // Validate JWT produced by safetynet
-func verifySafetyNetPayload(params requestJSONObject) (bool, LogFields) {
+func verifySafetyNetPayload(params common.APIParameters) (bool, LogFields) {
 
 	jwt, err := newJwt(params)
 	if err != nil {

+ 1 - 1
psiphon/server/server_test.go

@@ -75,7 +75,7 @@ func TestMain(m *testing.M) {
 	}
 	defer os.RemoveAll(testDataDirName)
 
-	os.Remove(filepath.Join(testDataDirName, psiphon.DATA_STORE_FILENAME))
+	os.Remove(filepath.Join(testDataDirName, psiphon.DATA_STORE_FILE_NAME))
 
 	psiphon.SetEmitDiagnosticNotices(true)
 

+ 18 - 1
psiphon/server/services.go

@@ -36,6 +36,7 @@ import (
 
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/osl"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/tactics"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/tun"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/server/psinet"
 )
@@ -405,6 +406,7 @@ type SupportServices struct {
 	DNSResolver        *DNSResolver
 	TunnelServer       *TunnelServer
 	PacketTunnelServer *tun.Server
+	TacticsServer      *tactics.Server
 }
 
 // NewSupportServices initializes a new SupportServices.
@@ -436,6 +438,15 @@ func NewSupportServices(config *Config) (*SupportServices, error) {
 		return nil, common.ContextError(err)
 	}
 
+	tacticsServer, err := tactics.NewServer(
+		CommonLogger(log),
+		getTacticsAPIParameterLogFieldFormatter(),
+		getTacticsAPIParameterValidator(config),
+		config.TacticsConfigFilename)
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
 	return &SupportServices{
 		Config:          config,
 		TrafficRulesSet: trafficRulesSet,
@@ -443,6 +454,7 @@ func NewSupportServices(config *Config) (*SupportServices, error) {
 		PsinetDatabase:  psinetDatabase,
 		GeoIPService:    geoIPService,
 		DNSResolver:     dnsResolver,
+		TacticsServer:   tacticsServer,
 	}, nil
 }
 
@@ -455,9 +467,14 @@ func (support *SupportServices) Reload() {
 		[]common.Reloader{
 			support.TrafficRulesSet,
 			support.OSLConfig,
-			support.PsinetDatabase},
+			support.PsinetDatabase,
+			support.TacticsServer},
 		support.GeoIPService.Reloaders()...)
 
+	// Note: established clients aren't notified when tactics change after a
+	// reload; new tactics will be obtained on the next client handshake or
+	// tactics request.
+
 	// Take these actions only after the corresponding Reloader has reloaded.
 	// In both the traffic rules and OSL cases, there is some impact from state
 	// reset, so the reset should be avoided where possible.

+ 12 - 3
psiphon/server/tunnelServer.go

@@ -52,6 +52,8 @@ const (
 	SSH_CONNECTION_READ_DEADLINE          = 5 * time.Minute
 	SSH_TCP_PORT_FORWARD_COPY_BUFFER_SIZE = 8192
 	SSH_TCP_PORT_FORWARD_QUEUE_SIZE       = 1024
+	SSH_KEEP_ALIVE_PAYLOAD_MIN_BYTES      = 0
+	SSH_KEEP_ALIVE_PAYLOAD_MAX_BYTES      = 256
 	SSH_SEND_OSL_INITIAL_RETRY_DELAY      = 30 * time.Second
 	SSH_SEND_OSL_RETRY_FACTOR             = 2
 	OSL_SESSION_CACHE_TTL                 = 5 * time.Minute
@@ -895,7 +897,7 @@ type qualityMetrics struct {
 type handshakeState struct {
 	completed             bool
 	apiProtocol           string
-	apiParams             requestJSONObject
+	apiParams             common.APIParameters
 	authorizedAccessTypes []string
 	expectDomainBytes     bool
 }
@@ -1115,7 +1117,7 @@ func (sshClient *sshClient) passwordCallback(conn ssh.ConnMetadata, password []b
 		}
 	}
 
-	if !isHexDigits(sshClient.sshServer.support, sshPasswordPayload.SessionId) ||
+	if !isHexDigits(sshClient.sshServer.support.Config, sshPasswordPayload.SessionId) ||
 		len(sshPasswordPayload.SessionId) != expectedSessionIDLength {
 		return nil, common.ContextError(fmt.Errorf("invalid session ID for %q", conn.User()))
 	}
@@ -1232,7 +1234,14 @@ func (sshClient *sshClient) runTunnel(
 			var err error
 
 			if request.Type == "keepalive@openssh.com" {
-				// Keepalive requests have an empty response.
+				// Random padding to frustrate fingerprinting.
+				responsePayload, err = common.MakeSecureRandomPadding(
+					SSH_KEEP_ALIVE_PAYLOAD_MIN_BYTES, SSH_KEEP_ALIVE_PAYLOAD_MAX_BYTES)
+				if err != nil {
+					// Proceed without random padding.
+					responsePayload = make([]byte, 0)
+					err = nil
+				}
 			} else {
 
 				// All other requests are assumed to be API requests.

+ 3 - 3
psiphon/server/webServer.go

@@ -163,9 +163,9 @@ func RunWebServer(
 func convertHTTPRequestToAPIRequest(
 	w http.ResponseWriter,
 	r *http.Request,
-	requestBodyName string) (requestJSONObject, error) {
+	requestBodyName string) (common.APIParameters, error) {
 
-	params := make(requestJSONObject)
+	params := make(common.APIParameters)
 
 	for name, values := range r.URL.Query() {
 		for _, value := range values {
@@ -216,7 +216,7 @@ func convertHTTPRequestToAPIRequest(
 	return params, nil
 }
 
-func (webServer *webServer) lookupGeoIPData(params requestJSONObject) GeoIPData {
+func (webServer *webServer) lookupGeoIPData(params common.APIParameters) GeoIPData {
 
 	clientSessionID, err := getStringRequestParam(params, "client_session_id")
 	if err != nil {

+ 81 - 14
psiphon/serverApi.go

@@ -36,7 +36,9 @@ import (
 	"sync/atomic"
 
 	"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/protocol"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/tactics"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/transferstats"
 )
 
@@ -105,7 +107,9 @@ func NewServerContext(tunnel *Tunnel) (*ServerContext, error) {
 		psiphonHttpsClient: psiphonHttpsClient,
 	}
 
-	err := serverContext.doHandshakeRequest(tunnel.config.IgnoreHandshakeStatsRegexps)
+	ignoreRegexps := tunnel.config.clientParameters.Get().Bool(parameters.IgnoreHandshakeStatsRegexps)
+
+	err := serverContext.doHandshakeRequest(ignoreRegexps)
 	if err != nil {
 		return nil, common.ContextError(err)
 	}
@@ -121,6 +125,25 @@ func (serverContext *ServerContext) doHandshakeRequest(
 
 	params := serverContext.getBaseParams()
 
+	doTactics := serverContext.tunnel.config.NetworkIDGetter != nil
+	networkID := ""
+	if doTactics {
+
+		// Limitation: it is assumed that the network ID obtained here is the
+		// one that is active when the tactics request is received by the
+		// server. However, it is remotely possible to switch networks
+		// immediately after invoking the GetNetworkID callback and initiating
+		// the handshake, if the tunnel protocol is meek.
+
+		networkID = serverContext.tunnel.config.NetworkIDGetter.GetNetworkID()
+
+		err := tactics.SetTacticsAPIParameters(
+			serverContext.tunnel.config.clientParameters, GetTacticsStorer(), networkID, params)
+		if err != nil {
+			return common.ContextError(err)
+		}
+	}
+
 	var response []byte
 	if serverContext.psiphonHttpsClient == nil {
 
@@ -190,7 +213,7 @@ func (serverContext *ServerContext) doHandshakeRequest(
 		err = protocol.ValidateServerEntry(serverEntry)
 		if err != nil {
 			// Skip this entry and continue with the next one
-			NoticeAlert("invalid server entry: %s", err)
+			NoticeAlert("invalid handshake server entry: %s", err)
 			continue
 		}
 
@@ -231,6 +254,36 @@ func (serverContext *ServerContext) doHandshakeRequest(
 
 	NoticeActiveAuthorizationIDs(handshakeResponse.ActiveAuthorizationIDs)
 
+	if doTactics && handshakeResponse.TacticsPayload != nil {
+
+		var payload *tactics.Payload
+		err = json.Unmarshal(handshakeResponse.TacticsPayload, &payload)
+		if err != nil {
+			return common.ContextError(err)
+		}
+
+		tacticsRecord, err := tactics.HandleTacticsPayload(
+			GetTacticsStorer(),
+			networkID,
+			payload)
+		if err != nil {
+			return common.ContextError(err)
+		}
+
+		if tacticsRecord != nil &&
+			common.FlipWeightedCoin(tacticsRecord.Tactics.Probability) {
+
+			err := serverContext.tunnel.config.SetClientParameters(
+				tacticsRecord.Tag, true, tacticsRecord.Tactics.Parameters)
+			if err != nil {
+				NoticeInfo("apply handshake 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.
+		}
+	}
+
 	return nil
 }
 
@@ -312,6 +365,7 @@ func (serverContext *ServerContext) DoStatusRequest(tunnel *Tunnel) error {
 	// payload for future attempt, in all failure cases.
 
 	statusPayload, statusPayloadInfo, err := makeStatusRequestPayload(
+		serverContext.tunnel.config.clientParameters,
 		tunnel.serverEntry.IpAddress)
 	if err != nil {
 		return common.ContextError(err)
@@ -360,16 +414,21 @@ func (serverContext *ServerContext) DoStatusRequest(tunnel *Tunnel) error {
 	return nil
 }
 
-func (serverContext *ServerContext) getStatusParams(isTunneled bool) requestJSONObject {
+func (serverContext *ServerContext) getStatusParams(
+	isTunneled bool) common.APIParameters {
 
 	params := serverContext.getBaseParams()
 
 	// Add a random amount of padding to help prevent stats updates from being
 	// a predictable size (which often happens when the connection is quiet).
 	// TODO: base64 encoding of padding means the padding size is not exactly
-	// [0, PADDING_MAX_BYTES].
+	// [PADDING_MIN_BYTES, PADDING_MAX_BYTES].
 
-	randomPadding, err := common.MakeSecureRandomPadding(0, PSIPHON_API_STATUS_REQUEST_PADDING_MAX_BYTES)
+	p := serverContext.tunnel.config.clientParameters.Get()
+	randomPadding, err := common.MakeSecureRandomPadding(
+		p.Int(parameters.PsiphonAPIStatusRequestPaddingMinBytes),
+		p.Int(parameters.PsiphonAPIStatusRequestPaddingMaxBytes))
+	p = nil
 	if err != nil {
 		NoticeAlert("MakeSecureRandomPadding failed: %s", common.ContextError(err))
 		// Proceed without random padding
@@ -404,13 +463,15 @@ type statusRequestPayloadInfo struct {
 }
 
 func makeStatusRequestPayload(
+	clientParameters *parameters.ClientParameters,
 	serverId string) ([]byte, *statusRequestPayloadInfo, error) {
 
 	transferStats := transferstats.TakeOutStatsForServer(serverId)
 	hostBytes := transferStats.GetStatsForStatusRequest()
 
-	persistentStats, err := TakeOutUnreportedPersistentStats(
-		PSIPHON_API_PERSISTENT_STATS_MAX_COUNT)
+	maxCount := clientParameters.Get().Int(parameters.PsiphonAPIPersistentStatsMaxCount)
+
+	persistentStats, err := TakeOutUnreportedPersistentStats(maxCount)
 	if err != nil {
 		NoticeAlert(
 			"TakeOutUnreportedPersistentStats failed: %s", common.ContextError(err))
@@ -660,14 +721,12 @@ func (serverContext *ServerContext) doPostRequest(
 	return responseBody, nil
 }
 
-type requestJSONObject map[string]interface{}
-
 // getBaseParams returns all the common API parameters that are included
 // with each Psiphon API request. These common parameters are used for
 // statistics.
-func (serverContext *ServerContext) getBaseParams() requestJSONObject {
+func (serverContext *ServerContext) getBaseParams() common.APIParameters {
 
-	params := make(requestJSONObject)
+	params := make(common.APIParameters)
 
 	tunnel := serverContext.tunnel
 
@@ -752,11 +811,13 @@ func (serverContext *ServerContext) getBaseParams() requestJSONObject {
 		params["server_entry_timestamp"] = localServerEntryTimestamp
 	}
 
+	params[tactics.APPLIED_TACTICS_TAG_PARAMETER_NAME] = tunnel.config.clientParameters.Get().Tag()
+
 	return params
 }
 
 // makeSSHAPIRequestPayload makes a JSON payload for an SSH API request.
-func makeSSHAPIRequestPayload(params requestJSONObject) ([]byte, error) {
+func makeSSHAPIRequestPayload(params common.APIParameters) ([]byte, error) {
 	jsonPayload, err := json.Marshal(params)
 	if err != nil {
 		return nil, common.ContextError(err)
@@ -765,7 +826,7 @@ func makeSSHAPIRequestPayload(params requestJSONObject) ([]byte, error) {
 }
 
 // makeRequestUrl makes a URL for a web service API request.
-func makeRequestUrl(tunnel *Tunnel, port, path string, params requestJSONObject) string {
+func makeRequestUrl(tunnel *Tunnel, port, path string, params common.APIParameters) string {
 	var requestUrl bytes.Buffer
 
 	if port == "" {
@@ -784,6 +845,11 @@ func makeRequestUrl(tunnel *Tunnel, port, path string, params requestJSONObject)
 		queryParams := url.Values{}
 
 		for name, value := range params {
+
+			// Note: this logic skips the tactics.SPEED_TEST_SAMPLES_PARAMETER_NAME
+			// parameter, which has a different type. This parameter is not recognized
+			// by legacy servers.
+
 			strValue := ""
 			switch v := value.(type) {
 			case string:
@@ -829,7 +895,8 @@ func makePsiphonHttpsClient(tunnel *Tunnel) (httpsClient *http.Client, err error
 
 	dialer := NewCustomTLSDialer(
 		&CustomTLSConfig{
-			Dial: tunneledDialer,
+			ClientParameters: tunnel.config.clientParameters,
+			Dial:             tunneledDialer,
 			VerifyLegacyCertificate: certificate,
 		})
 

+ 42 - 26
psiphon/splitTunnel.go

@@ -33,6 +33,7 @@ import (
 
 	"github.com/Psiphon-Inc/goarista/monotime"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters"
 )
 
 // SplitTunnelClassifier determines whether a network destination
@@ -67,16 +68,14 @@ import (
 // data is cached in the data store so it need not be downloaded in full
 // when fresh data is in the cache.
 type SplitTunnelClassifier struct {
-	mutex                    sync.RWMutex
-	fetchRoutesUrlFormat     string
-	userAgent                string
-	routesSignaturePublicKey string
-	dnsServerAddress         string
-	dnsTunneler              Tunneler
-	fetchRoutesWaitGroup     *sync.WaitGroup
-	isRoutesSet              bool
-	cache                    map[string]*classification
-	routes                   common.SubnetLookup
+	mutex                sync.RWMutex
+	clientParameters     *parameters.ClientParameters
+	userAgent            string
+	dnsTunneler          Tunneler
+	fetchRoutesWaitGroup *sync.WaitGroup
+	isRoutesSet          bool
+	cache                map[string]*classification
+	routes               common.SubnetLookup
 }
 
 type classification struct {
@@ -86,14 +85,12 @@ type classification struct {
 
 func NewSplitTunnelClassifier(config *Config, tunneler Tunneler) *SplitTunnelClassifier {
 	return &SplitTunnelClassifier{
-		fetchRoutesUrlFormat:     config.SplitTunnelRoutesUrlFormat,
-		userAgent:                MakePsiphonUserAgent(config),
-		routesSignaturePublicKey: config.SplitTunnelRoutesSignaturePublicKey,
-		dnsServerAddress:         config.SplitTunnelDnsServer,
-		dnsTunneler:              tunneler,
-		fetchRoutesWaitGroup:     new(sync.WaitGroup),
-		isRoutesSet:              false,
-		cache:                    make(map[string]*classification),
+		clientParameters:     config.clientParameters,
+		userAgent:            MakePsiphonUserAgent(config),
+		dnsTunneler:          tunneler,
+		fetchRoutesWaitGroup: new(sync.WaitGroup),
+		isRoutesSet:          false,
+		cache:                make(map[string]*classification),
 	}
 }
 
@@ -108,9 +105,15 @@ func (classifier *SplitTunnelClassifier) Start(fetchRoutesTunnel *Tunnel) {
 
 	classifier.isRoutesSet = false
 
-	if classifier.dnsServerAddress == "" ||
-		classifier.routesSignaturePublicKey == "" ||
-		classifier.fetchRoutesUrlFormat == "" {
+	p := classifier.clientParameters.Get()
+	dnsServerAddress := p.String(parameters.SplitTunnelDNSServer)
+	routesSignaturePublicKey := p.String(parameters.SplitTunnelRoutesSignaturePublicKey)
+	fetchRoutesUrlFormat := p.String(parameters.SplitTunnelRoutesURLFormat)
+	p = nil
+
+	if dnsServerAddress == "" ||
+		routesSignaturePublicKey == "" ||
+		fetchRoutesUrlFormat == "" {
 		// Split tunnel capability is not configured
 		return
 	}
@@ -157,6 +160,13 @@ func (classifier *SplitTunnelClassifier) IsUntunneled(targetAddress string) bool
 		return false
 	}
 
+	dnsServerAddress := classifier.clientParameters.Get().String(
+		parameters.SplitTunnelDNSServer)
+	if dnsServerAddress == "" {
+		// Split tunnel has been disabled.
+		return false
+	}
+
 	classifier.mutex.RLock()
 	cachedClassification, ok := classifier.cache[targetAddress]
 	classifier.mutex.RUnlock()
@@ -165,7 +175,7 @@ func (classifier *SplitTunnelClassifier) IsUntunneled(targetAddress string) bool
 	}
 
 	ipAddr, ttl, err := tunneledLookupIP(
-		classifier.dnsServerAddress, classifier.dnsTunneler, targetAddress)
+		dnsServerAddress, classifier.dnsTunneler, targetAddress)
 	if err != nil {
 		NoticeAlert("failed to resolve address for split tunnel classification: %s", err)
 		return false
@@ -217,7 +227,13 @@ func (classifier *SplitTunnelClassifier) setRoutes(tunnel *Tunnel) {
 // fails and cached routes data is present, that cached data is returned.
 func (classifier *SplitTunnelClassifier) getRoutes(tunnel *Tunnel) (routesData []byte, err error) {
 
-	url := fmt.Sprintf(classifier.fetchRoutesUrlFormat, tunnel.serverContext.clientRegion)
+	p := classifier.clientParameters.Get()
+	routesSignaturePublicKey := p.String(parameters.SplitTunnelRoutesSignaturePublicKey)
+	fetchRoutesUrlFormat := p.String(parameters.SplitTunnelRoutesURLFormat)
+	fetchTimeout := p.Duration(parameters.FetchSplitTunnelRoutesTimeout)
+	p = nil
+
+	url := fmt.Sprintf(fetchRoutesUrlFormat, tunnel.serverContext.clientRegion)
 	request, err := http.NewRequest("GET", url, nil)
 	if err != nil {
 		return nil, common.ContextError(err)
@@ -238,11 +254,11 @@ func (classifier *SplitTunnelClassifier) getRoutes(tunnel *Tunnel) (routesData [
 	}
 	transport := &http.Transport{
 		Dial: tunneledDialer,
-		ResponseHeaderTimeout: time.Duration(*tunnel.config.FetchRoutesTimeoutSeconds) * time.Second,
+		ResponseHeaderTimeout: fetchTimeout,
 	}
 	httpClient := &http.Client{
 		Transport: transport,
-		Timeout:   time.Duration(*tunnel.config.FetchRoutesTimeoutSeconds) * time.Second,
+		Timeout:   fetchTimeout,
 	}
 
 	// At this time, the largest uncompressed routes data set is ~1MB. For now,
@@ -281,7 +297,7 @@ func (classifier *SplitTunnelClassifier) getRoutes(tunnel *Tunnel) (routesData [
 	var encodedRoutesData string
 	if !useCachedRoutes {
 		encodedRoutesData, err = common.ReadAuthenticatedDataPackage(
-			routesDataPackage, false, classifier.routesSignaturePublicKey)
+			routesDataPackage, false, routesSignaturePublicKey)
 		if err != nil {
 			NoticeAlert("failed to read split tunnel routes package: %s", common.ContextError(err))
 			useCachedRoutes = true

+ 10 - 1
psiphon/tlsDialer.go

@@ -80,6 +80,7 @@ import (
 	"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/tls"
 )
 
@@ -92,6 +93,10 @@ const (
 // of CustomTLSDial.
 type CustomTLSConfig struct {
 
+	// ClientParameters is the active set of client parameters to use
+	// for the TLS dial.
+	ClientParameters *parameters.ClientParameters
+
 	// Dial is the network connection dialer. TLS is layered on
 	// top of a new network connection created with dialer.
 	Dial Dialer
@@ -149,6 +154,7 @@ type CustomTLSConfig struct {
 }
 
 func SelectTLSProfile(
+	clientParameters *parameters.ClientParameters,
 	useIndistinguishableTLS, useObfuscatedSessionTickets,
 	skipVerify, haveTrustedCACertificates bool) string {
 
@@ -162,7 +168,9 @@ func SelectTLSProfile(
 			// TODO: (... || config.VerifyLegacyCertificate != nil)
 			(skipVerify || haveTrustedCACertificates)
 
-		if canUseOpenSSL && common.FlipCoin() {
+		if canUseOpenSSL &&
+			clientParameters.Get().WeightedCoinFlip(parameters.SelectAndroidTLSProbability) {
+
 			selectedTLSProfile = TLSProfileAndroid
 		} else {
 			selectedTLSProfile = TLSProfileChrome
@@ -221,6 +229,7 @@ func CustomTLSDial(
 
 		if selectedTLSProfile == "" {
 			selectedTLSProfile = SelectTLSProfile(
+				config.ClientParameters,
 				true,
 				config.ObfuscatedSessionTicketKey != "",
 				config.SkipVerify,

+ 238 - 122
psiphon/tunnel.go

@@ -36,7 +36,9 @@ import (
 	"github.com/Psiphon-Inc/goarista/monotime"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/crypto/ssh"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters"
 	"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/transferstats"
 	regen "github.com/zach-klippenstein/goregen"
 )
@@ -207,10 +209,12 @@ func (tunnel *Tunnel) Activate(
 		// request. At this point, there is no operateTunnel monitor that will detect
 		// this condition with SSH keep alives.
 
-		if *tunnel.config.PsiphonApiServerTimeoutSeconds > 0 {
+		timeout := tunnel.config.clientParameters.Get().Duration(
+			parameters.PsiphonAPIRequestTimeout)
+
+		if timeout > 0 {
 			var cancelFunc context.CancelFunc
-			ctx, cancelFunc = context.WithTimeout(
-				ctx, time.Second*time.Duration(*tunnel.config.PsiphonApiServerTimeoutSeconds))
+			ctx, cancelFunc = context.WithTimeout(ctx, timeout)
 			defer cancelFunc()
 		}
 
@@ -301,17 +305,24 @@ func (tunnel *Tunnel) Close(isDiscarded bool) {
 	tunnel.mutex.Unlock()
 
 	if !isClosed {
+
 		// Signal operateTunnel to stop before closing the tunnel -- this
 		// allows a final status request to be made in the case of an orderly
 		// shutdown.
 		// A timer is set, so if operateTunnel takes too long to stop, the
 		// tunnel is closed, which will interrupt any slow final status request.
+
 		if isActivated {
-			afterFunc := time.AfterFunc(TUNNEL_OPERATE_SHUTDOWN_TIMEOUT, func() { tunnel.conn.Close() })
+			timeout := tunnel.config.clientParameters.Get().Duration(
+				parameters.TunnelOperateShutdownTimeout)
+			afterFunc := time.AfterFunc(
+				timeout,
+				func() { tunnel.conn.Close() })
 			tunnel.stopOperate()
 			tunnel.operateWaitGroup.Wait()
 			afterFunc.Stop()
 		}
+
 		tunnel.sshClient.Close()
 		// tunnel.conn.Close() may get called multiple times, which is allowed.
 		tunnel.conn.Close()
@@ -381,12 +392,15 @@ func (tunnel *Tunnel) Dial(
 
 	resultChannel := make(chan *tunnelDialResult, 1)
 
-	if *tunnel.config.TunnelPortForwardDialTimeoutSeconds > 0 {
-		afterFunc := time.AfterFunc(time.Duration(*tunnel.config.TunnelPortForwardDialTimeoutSeconds)*time.Second, func() {
+	timeout := tunnel.config.clientParameters.Get().Duration(
+		parameters.TunnelPortForwardDialTimeout)
+
+	afterFunc := time.AfterFunc(
+		timeout,
+		func() {
 			resultChannel <- &tunnelDialResult{nil, errors.New("tunnel dial timeout")}
 		})
-		defer afterFunc.Stop()
-	}
+	defer afterFunc.Stop()
 
 	go func() {
 		sshPortForwardConn, err := tunnel.sshClient.Dial("tcp", remoteAddr)
@@ -519,40 +533,55 @@ func (conn *TunneledConn) Close() error {
 	return conn.Conn.Close()
 }
 
-var errProtocolNotSupported = errors.New("server does not support required protocol(s)")
+var errNoProtocolSupported = errors.New("server does not support any required protocol(s)")
 
 // selectProtocol is a helper that picks the tunnel protocol
 func selectProtocol(
 	config *Config,
 	serverEntry *protocol.ServerEntry,
-	excludeMeek bool) (selectedProtocol string, err error) {
-
-	// TODO: properly handle protocols (e.g. FRONTED-MEEK-OSSH) vs. capabilities (e.g., {FRONTED-MEEK, OSSH})
-	// for now, the code is simply assuming that MEEK capabilities imply OSSH capability.
-	if config.TunnelProtocol != "" {
-		if !serverEntry.SupportsProtocol(config.TunnelProtocol) ||
-			(excludeMeek && protocol.TunnelProtocolUsesMeek(config.TunnelProtocol)) {
-			return "", errProtocolNotSupported
-		}
-		selectedProtocol = config.TunnelProtocol
-	} else {
-		// Pick at random from the supported protocols. This ensures that we'll eventually
-		// try all possible protocols. Depending on network configuration, it may be the
-		// case that some protocol is only available through multi-capability servers,
-		// and a simpler ranked preference of protocols could lead to that protocol never
-		// being selected.
-
-		candidateProtocols := serverEntry.GetSupportedProtocols(excludeMeek)
-		if len(candidateProtocols) == 0 {
-			return "", errProtocolNotSupported
-		}
+	impairedProtocols []string,
+	excludeMeek bool,
+	usePriorityProtocol bool) (selectedProtocol string, err error) {
+
+	candidateProtocols := serverEntry.GetSupportedProtocols(
+		config.clientParameters.Get().TunnelProtocols(parameters.LimitTunnelProtocols),
+		impairedProtocols,
+		excludeMeek)
+	if len(candidateProtocols) == 0 {
+		return "", errNoProtocolSupported
+	}
 
-		index, err := common.MakeSecureRandomInt(len(candidateProtocols))
-		if err != nil {
-			return "", common.ContextError(err)
+	// Select a prioritized protocols when indicated. If no prioritized
+	// protocol is available, proceed with selecting any other protocol.
+
+	if usePriorityProtocol {
+		prioritizeProtocols := config.clientParameters.Get().TunnelProtocols(
+			parameters.PrioritizeTunnelProtocols)
+		if len(prioritizeProtocols) > 0 {
+			protocols := make([]string, 0)
+			for _, protocol := range candidateProtocols {
+				if common.Contains(prioritizeProtocols, protocol) {
+					protocols = append(protocols, protocol)
+				}
+			}
+			if len(protocols) > 0 {
+				candidateProtocols = protocols
+			}
 		}
-		selectedProtocol = candidateProtocols[index]
 	}
+
+	// Pick at random from the supported protocols. This ensures that we'll
+	// eventually try all possible protocols. Depending on network
+	// configuration, it may be the case that some protocol is only available
+	// through multi-capability servers, and a simpler ranked preference of
+	// protocols could lead to that protocol never being selected.
+
+	index, err := common.MakeSecureRandomInt(len(candidateProtocols))
+	if err != nil {
+		return "", common.ContextError(err)
+	}
+	selectedProtocol = candidateProtocols[index]
+
 	return selectedProtocol, nil
 }
 
@@ -598,16 +627,6 @@ func selectFrontingParameters(
 	return
 }
 
-func doMeekTransformHostName(config *Config) bool {
-	switch config.TransformHostNames {
-	case TRANSFORM_HOST_NAMES_ALWAYS:
-		return true
-	case TRANSFORM_HOST_NAMES_NEVER:
-		return false
-	}
-	return common.FlipCoin()
-}
-
 // initMeekConfig is a helper that creates a MeekConfig suitable for the
 // selected meek tunnel protocol.
 func initMeekConfig(
@@ -616,8 +635,10 @@ func initMeekConfig(
 	selectedProtocol,
 	sessionId string) (*MeekConfig, error) {
 
-	// The meek protocol always uses OSSH
-	psiphonServerAddress := fmt.Sprintf("%s:%d", serverEntry.IpAddress, serverEntry.SshObfuscatedPort)
+	doMeekTransformHostName := func() bool {
+		return config.clientParameters.Get().WeightedCoinFlip(
+			parameters.TransformHostNameProbability)
+	}
 
 	var dialAddress string
 	useHTTPS := false
@@ -636,7 +657,7 @@ func initMeekConfig(
 		useHTTPS = true
 		if !serverEntry.MeekFrontingDisableSNI {
 			SNIServerName = frontingAddress
-			if doMeekTransformHostName(config) {
+			if doMeekTransformHostName() {
 				SNIServerName = common.GenerateHostName()
 				transformedHostName = true
 			}
@@ -656,7 +677,7 @@ func initMeekConfig(
 
 		dialAddress = fmt.Sprintf("%s:%d", serverEntry.IpAddress, serverEntry.MeekServerPort)
 		hostname := serverEntry.IpAddress
-		if doMeekTransformHostName(config) {
+		if doMeekTransformHostName() {
 			hostname = common.GenerateHostName()
 			transformedHostName = true
 		}
@@ -675,7 +696,7 @@ func initMeekConfig(
 			useObfuscatedSessionTickets = true
 		}
 		SNIServerName = serverEntry.IpAddress
-		if doMeekTransformHostName(config) {
+		if doMeekTransformHostName() {
 			SNIServerName = common.GenerateHostName()
 			transformedHostName = true
 		}
@@ -689,6 +710,13 @@ func initMeekConfig(
 		return nil, common.ContextError(errors.New("unexpected selectedProtocol"))
 	}
 
+	if config.clientParameters.Get().Bool(parameters.MeekDialDomainsOnly) {
+		host, _, _ := net.SplitHostPort(dialAddress)
+		if net.ParseIP(host) != nil {
+			return nil, common.ContextError(errors.New("dial address is not domain"))
+		}
+	}
+
 	// The underlying TLS will automatically disable SNI for IP address server name
 	// values; we have this explicit check here so we record the correct value for stats.
 	if net.ParseIP(SNIServerName) != nil {
@@ -697,13 +725,14 @@ func initMeekConfig(
 
 	// Pin the TLS profile for the entire meek connection.
 	selectedTLSProfile := SelectTLSProfile(
+		config.clientParameters,
 		config.UseIndistinguishableTLS,
 		useObfuscatedSessionTickets,
 		true,
 		config.TrustedCACertificatesFilename != "")
 
 	return &MeekConfig{
-		LimitBufferSizes:              config.LimitMeekBufferSizes,
+		ClientParameters:              config.clientParameters,
 		DialAddress:                   dialAddress,
 		UseHTTPS:                      useHTTPS,
 		TLSProfile:                    selectedTLSProfile,
@@ -711,14 +740,66 @@ func initMeekConfig(
 		SNIServerName:                 SNIServerName,
 		HostHeader:                    hostHeader,
 		TransformedHostName:           transformedHostName,
-		PsiphonServerAddress:          psiphonServerAddress,
-		SessionID:                     sessionId,
 		ClientTunnelProtocol:          selectedProtocol,
 		MeekCookieEncryptionPublicKey: serverEntry.MeekCookieEncryptionPublicKey,
 		MeekObfuscatedKey:             serverEntry.MeekObfuscatedKey,
 	}, nil
 }
 
+// initDialConfig is a helper that creates a DialConfig for the tunnel.
+func initDialConfig(
+	config *Config,
+	meekConfig *MeekConfig) (*DialConfig, string, bool) {
+
+	var upstreamProxyType string
+
+	if config.UpstreamProxyURL != "" {
+		// Note: UpstreamProxyURL will be validated in the dial
+		proxyURL, err := url.Parse(config.UpstreamProxyURL)
+		if err == nil {
+			upstreamProxyType = proxyURL.Scheme
+		}
+	}
+
+	dialCustomHeaders := make(map[string][]string)
+	if config.CustomHeaders != nil {
+		for k, v := range config.CustomHeaders {
+			dialCustomHeaders[k] = make([]string, len(v))
+			copy(dialCustomHeaders[k], v)
+		}
+	}
+
+	additionalCustomHeaders :=
+		config.clientParameters.Get().HTTPHeaders(parameters.AdditionalCustomHeaders)
+
+	if additionalCustomHeaders != nil {
+		for k, v := range additionalCustomHeaders {
+			dialCustomHeaders[k] = make([]string, len(v))
+			copy(dialCustomHeaders[k], v)
+		}
+	}
+
+	// Set User-Agent when using meek or an upstream HTTP proxy
+
+	var selectedUserAgent bool
+	if meekConfig != nil || upstreamProxyType == "http" {
+		selectedUserAgent = UserAgentIfUnset(config.clientParameters, dialCustomHeaders)
+	}
+
+	return &DialConfig{
+			UpstreamProxyURL:              config.UpstreamProxyURL,
+			CustomHeaders:                 dialCustomHeaders,
+			DeviceBinder:                  config.DeviceBinder,
+			DnsServerGetter:               config.DnsServerGetter,
+			IPv6Synthesizer:               config.IPv6Synthesizer,
+			UseIndistinguishableTLS:       config.UseIndistinguishableTLS,
+			TrustedCACertificatesFilename: config.TrustedCACertificatesFilename,
+			DeviceRegion:                  config.DeviceRegion,
+		},
+		upstreamProxyType,
+		selectedUserAgent
+}
+
 type dialResult struct {
 	dialConn      net.Conn
 	monitoredConn *common.ActivityMonitoredConn
@@ -741,12 +822,11 @@ func dialSsh(
 	selectedProtocol,
 	sessionId string) (*dialResult, error) {
 
-	if *config.TunnelConnectTimeoutSeconds > 0 {
-		var cancelFunc context.CancelFunc
-		ctx, cancelFunc = context.WithTimeout(
-			ctx, time.Second*time.Duration(*config.TunnelConnectTimeoutSeconds))
-		defer cancelFunc()
-	}
+	timeout := config.clientParameters.Get().Duration(parameters.TunnelConnectTimeout)
+
+	var cancelFunc context.CancelFunc
+	ctx, cancelFunc = context.WithTimeout(ctx, timeout)
+	defer cancelFunc()
 
 	// The meek protocols tunnel obfuscated SSH. Obfuscated SSH is layered on top of SSH.
 	// So depending on which protocol is used, multiple layers are initialized.
@@ -778,23 +858,7 @@ func dialSsh(
 		}
 	}
 
-	// Set User Agent when using meek or an upstream HTTP proxy
-
-	var selectedUserAgent bool
-	dialCustomHeaders := config.CustomHeaders
-	var upstreamProxyType string
-
-	if config.UpstreamProxyUrl != "" {
-		// Note: UpstreamProxyUrl will be validated in the dial
-		proxyURL, err := url.Parse(config.UpstreamProxyUrl)
-		if err == nil {
-			upstreamProxyType = proxyURL.Scheme
-		}
-	}
-
-	if meekConfig != nil || upstreamProxyType == "http" {
-		dialCustomHeaders, selectedUserAgent = UserAgentIfUnset(dialCustomHeaders)
-	}
+	dialConfig, upstreamProxyType, selectedUserAgent := initDialConfig(config, meekConfig)
 
 	// Use an asynchronous callback to record the resolved IP address when
 	// dialing a domain name. Note that DialMeek doesn't immediately
@@ -804,22 +868,10 @@ func dialSsh(
 	// has completed to ensure a result.
 	var resolvedIPAddress atomic.Value
 	resolvedIPAddress.Store("")
-	setResolvedIPAddress := func(IPAddress string) {
+	dialConfig.ResolvedIPCallback = func(IPAddress string) {
 		resolvedIPAddress.Store(IPAddress)
 	}
 
-	dialConfig := &DialConfig{
-		UpstreamProxyUrl:              config.UpstreamProxyUrl,
-		CustomHeaders:                 dialCustomHeaders,
-		DeviceBinder:                  config.DeviceBinder,
-		DnsServerGetter:               config.DnsServerGetter,
-		IPv6Synthesizer:               config.IPv6Synthesizer,
-		UseIndistinguishableTLS:       config.UseIndistinguishableTLS,
-		TrustedCACertificatesFilename: config.TrustedCACertificatesFilename,
-		DeviceRegion:                  config.DeviceRegion,
-		ResolvedIPCallback:            setResolvedIPAddress,
-	}
-
 	// Gather dial parameters for diagnostic logging and stats reporting
 
 	dialStats := &TunnelDialStats{}
@@ -902,7 +954,9 @@ func dialSsh(
 	}
 
 	// Apply throttling (if configured)
-	throttledConn := common.NewThrottledConn(monitoredConn, config.RateLimits)
+	throttledConn := common.NewThrottledConn(
+		monitoredConn,
+		config.clientParameters.Get().RateLimits(parameters.TunnelRateLimits))
 
 	// Add obfuscated SSH layer
 	var sshConn net.Conn = throttledConn
@@ -1095,6 +1149,8 @@ func makeRandomPeriod(min, max time.Duration) time.Duration {
 func (tunnel *Tunnel) operateTunnel(tunnelOwner TunnelOwner) {
 	defer tunnel.operateWaitGroup.Done()
 
+	clientParameters := tunnel.config.clientParameters
+
 	lastBytesReceivedTime := monotime.Now()
 
 	lastTotalBytesTransferedTime := monotime.Now()
@@ -1108,9 +1164,10 @@ func (tunnel *Tunnel) operateTunnel(tunnelOwner TunnelOwner) {
 	// from a range, to make the resulting traffic less fingerprintable,
 	// Note: not using Tickers since these are not fixed time periods.
 	nextStatusRequestPeriod := func() time.Duration {
+		p := clientParameters.Get()
 		return makeRandomPeriod(
-			PSIPHON_API_STATUS_REQUEST_PERIOD_MIN,
-			PSIPHON_API_STATUS_REQUEST_PERIOD_MAX)
+			p.Duration(parameters.PsiphonAPIStatusRequestPeriodMin),
+			p.Duration(parameters.PsiphonAPIStatusRequestPeriodMax))
 	}
 
 	statsTimer := time.NewTimer(nextStatusRequestPeriod())
@@ -1121,15 +1178,18 @@ func (tunnel *Tunnel) operateTunnel(tunnelOwner TunnelOwner) {
 	unreported := CountUnreportedPersistentStats()
 	if unreported > 0 {
 		NoticeInfo("Unreported persistent stats: %d", unreported)
-		statsTimer.Reset(makeRandomPeriod(
-			PSIPHON_API_STATUS_REQUEST_SHORT_PERIOD_MIN,
-			PSIPHON_API_STATUS_REQUEST_SHORT_PERIOD_MAX))
+		p := clientParameters.Get()
+		statsTimer.Reset(
+			makeRandomPeriod(
+				p.Duration(parameters.PsiphonAPIStatusRequestShortPeriodMin),
+				p.Duration(parameters.PsiphonAPIStatusRequestShortPeriodMax)))
 	}
 
 	nextSshKeepAlivePeriod := func() time.Duration {
+		p := clientParameters.Get()
 		return makeRandomPeriod(
-			TUNNEL_SSH_KEEP_ALIVE_PERIOD_MIN,
-			TUNNEL_SSH_KEEP_ALIVE_PERIOD_MAX)
+			p.Duration(parameters.SSHKeepAlivePeriodMin),
+			p.Duration(parameters.SSHKeepAlivePeriodMax))
 	}
 
 	// TODO: don't initialize timer when config.DisablePeriodicSshKeepAlive is set
@@ -1158,14 +1218,16 @@ func (tunnel *Tunnel) operateTunnel(tunnelOwner TunnelOwner) {
 	sshKeepAliveError := make(chan error, 1)
 	go func() {
 		defer requestsWaitGroup.Done()
+		isFirstKeepAlive := true
 		for timeout := range signalSshKeepAlive {
-			err := sendSshKeepAlive(tunnel.sshClient, tunnel.conn, timeout)
+			err := tunnel.sendSshKeepAlive(isFirstKeepAlive, timeout)
 			if err != nil {
 				select {
 				case sshKeepAliveError <- err:
 				default:
 				}
 			}
+			isFirstKeepAlive = false
 		}
 	}()
 
@@ -1187,16 +1249,23 @@ func (tunnel *Tunnel) operateTunnel(tunnelOwner TunnelOwner) {
 					return
 				}
 			} else {
+
+				maxRetries := clientParameters.Get().Int(
+					parameters.PsiphonAPIClientVerificationRequestMaxRetries)
+
 				// If sendClientVerification failed to send the payload we
 				// will retry after a delay. Will use a new payload instead
 				// if that arrives in the meantime.
-				// If failures count is more than PSIPHON_API_CLIENT_VERIFICATION_REQUEST_MAX_RETRIES
-				// stop retrying for this tunnel.
 				failCount += 1
-				if failCount > PSIPHON_API_CLIENT_VERIFICATION_REQUEST_MAX_RETRIES {
+				if failCount > maxRetries {
 					return
 				}
-				timer := time.NewTimer(PSIPHON_API_CLIENT_VERIFICATION_REQUEST_RETRY_PERIOD)
+
+				timeout := clientParameters.Get().Duration(
+					parameters.PsiphonAPIClientVerificationRequestRetryPeriod)
+
+				timer := time.NewTimer(timeout)
+
 				doReturn := false
 				select {
 				case <-timer.C:
@@ -1229,7 +1298,9 @@ func (tunnel *Tunnel) operateTunnel(tunnelOwner TunnelOwner) {
 			totalSent += sent
 			totalReceived += received
 
-			if lastTotalBytesTransferedTime.Add(TOTAL_BYTES_TRANSFERRED_NOTICE_PERIOD).Before(monotime.Now()) {
+			noticePeriod := clientParameters.Get().Duration(parameters.TotalBytesTransferredNoticePeriod)
+
+			if lastTotalBytesTransferedTime.Add(noticePeriod).Before(monotime.Now()) {
 				NoticeTotalBytesTransferred(tunnel.serverEntry.IpAddress, totalSent, totalReceived)
 				lastTotalBytesTransferedTime = monotime.Now()
 			}
@@ -1247,9 +1318,11 @@ func (tunnel *Tunnel) operateTunnel(tunnelOwner TunnelOwner) {
 			statsTimer.Reset(nextStatusRequestPeriod())
 
 		case <-sshKeepAliveTimer.C:
-			if lastBytesReceivedTime.Add(TUNNEL_SSH_KEEP_ALIVE_PERIODIC_INACTIVE_PERIOD).Before(monotime.Now()) {
+			inactivePeriod := clientParameters.Get().Duration(parameters.SSHKeepAlivePeriodicInactivePeriod)
+			if lastBytesReceivedTime.Add(inactivePeriod).Before(monotime.Now()) {
+				timeout := clientParameters.Get().Duration(parameters.SSHKeepAlivePeriodicTimeout)
 				select {
-				case signalSshKeepAlive <- time.Duration(*tunnel.config.TunnelSshKeepAlivePeriodicTimeoutSeconds) * time.Second:
+				case signalSshKeepAlive <- timeout:
 				default:
 				}
 			}
@@ -1268,9 +1341,11 @@ func (tunnel *Tunnel) operateTunnel(tunnelOwner TunnelOwner) {
 			if tunnel.conn.IsClosed() {
 				err = errors.New("underlying conn is closed")
 			} else {
-				if lastBytesReceivedTime.Add(TUNNEL_SSH_KEEP_ALIVE_PROBE_INACTIVE_PERIOD).Before(monotime.Now()) {
+				inactivePeriod := clientParameters.Get().Duration(parameters.SSHKeepAliveProbeInactivePeriod)
+				if lastBytesReceivedTime.Add(inactivePeriod).Before(monotime.Now()) {
+					timeout := clientParameters.Get().Duration(parameters.SSHKeepAliveProbeTimeout)
 					select {
-					case signalSshKeepAlive <- time.Duration(*tunnel.config.TunnelSshKeepAliveProbeTimeoutSeconds) * time.Second:
+					case signalSshKeepAlive <- timeout:
 					default:
 					}
 				}
@@ -1319,7 +1394,7 @@ func (tunnel *Tunnel) operateTunnel(tunnelOwner TunnelOwner) {
 		// domain bytes transferred stats as well as to report session stats
 		// as soon as possible.
 		// This request will be interrupted when the tunnel is closed after
-		// TUNNEL_OPERATE_SHUTDOWN_TIMEOUT.
+		// an operate shutdown timeout.
 		sendStats(tunnel)
 
 	} else {
@@ -1332,8 +1407,7 @@ func (tunnel *Tunnel) operateTunnel(tunnelOwner TunnelOwner) {
 // on the specified SSH connections and returns true of the request succeeds
 // within a specified timeout. If the request fails, the associated conn is
 // closed, which will terminate the associated tunnel.
-func sendSshKeepAlive(
-	sshClient *ssh.Client, conn net.Conn, timeout time.Duration) error {
+func (tunnel *Tunnel) sendSshKeepAlive(isFirstKeepAlive bool, timeout time.Duration) error {
 
 	// Note: there is no request context since SSH requests cannot be
 	// interrupted directly. Closing the tunnel will interrupt the request.
@@ -1343,31 +1417,73 @@ func sendSshKeepAlive(
 	// Use a buffer of 1 as there are two senders and only one guaranteed receive.
 
 	errChannel := make(chan error, 1)
-	if timeout > 0 {
-		afterFunc := time.AfterFunc(timeout, func() {
-			errChannel <- errors.New("timed out")
-		})
-		defer afterFunc.Stop()
-	}
+
+	afterFunc := time.AfterFunc(timeout, func() {
+		errChannel <- errors.New("timed out")
+	})
+	defer afterFunc.Stop()
 
 	go func() {
-		// Random padding to frustrate fingerprinting
-		randomPadding, err := common.MakeSecureRandomPadding(0, TUNNEL_SSH_KEEP_ALIVE_PAYLOAD_MAX_BYTES)
+		// Random padding to frustrate fingerprinting.
+		p := tunnel.config.clientParameters.Get()
+		randomPadding, err := common.MakeSecureRandomPadding(
+			p.Int(parameters.SSHKeepAlivePaddingMinBytes),
+			p.Int(parameters.SSHKeepAlivePaddingMaxBytes))
+		p = nil
 		if err != nil {
-			NoticeAlert("MakeSecureRandomPadding failed: %s", err)
-			// Proceed without random padding
+			NoticeAlert("MakeSecureRandomPadding failed: %s", common.ContextError(err))
+			// Proceed without random padding.
 			randomPadding = make([]byte, 0)
 		}
+
+		startTime := monotime.Now()
+
 		// Note: reading a reply is important for last-received-time tunnel
 		// duration calculation.
-		_, _, err = sshClient.SendRequest("keepalive@openssh.com", true, randomPadding)
+		requestOk, response, err := tunnel.sshClient.SendRequest(
+			"keepalive@openssh.com", true, randomPadding)
+
+		elaspedTime := monotime.Since(startTime)
+
 		errChannel <- err
+
+		// Record the keep alive round trip as a speed test sample. The first
+		// keep alive is always recorded, as many tunnels are short-lived and
+		// we want to ensure that some data is gathered. Subsequent keep
+		// alives are recorded with some configurable probability, which,
+		// considering that only the last SpeedTestMaxSampleCount samples are
+		// retained, enables tuning the sampling frequency.
+
+		if err == nil && requestOk && tunnel.config.NetworkIDGetter != nil &&
+			(isFirstKeepAlive ||
+				tunnel.config.clientParameters.Get().WeightedCoinFlip(
+					parameters.SSHKeepAliveSpeedTestSampleProbability)) {
+
+			// TODO: refactor code in common with FetchTactics?
+			sample := tactics.SpeedTestSample{
+				Timestamp:        time.Now(), // *TODO* use server time
+				EndPointRegion:   tunnel.serverEntry.Region,
+				EndPointProtocol: tunnel.protocol,
+				RTTMilliseconds:  int(elaspedTime / time.Millisecond),
+				BytesUp:          len(randomPadding),
+				BytesDown:        len(response),
+			}
+
+			err = tactics.AddSpeedTestSample(
+				tunnel.config.clientParameters,
+				GetTacticsStorer(),
+				tunnel.config.NetworkIDGetter.GetNetworkID(),
+				sample)
+			if err != nil {
+				NoticeAlert("AddSpeedTestSample failed: %s", common.ContextError(err))
+			}
+		}
 	}()
 
 	err := <-errChannel
 	if err != nil {
-		sshClient.Close()
-		conn.Close()
+		tunnel.sshClient.Close()
+		tunnel.conn.Close()
 	}
 
 	return common.ContextError(err)

+ 14 - 10
psiphon/upgradeDownload.go

@@ -25,9 +25,9 @@ import (
 	"net/http"
 	"os"
 	"strconv"
-	"time"
 
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters"
 )
 
 // DownloadUpgrade performs a resumable download of client upgrade files.
@@ -73,15 +73,19 @@ func DownloadUpgrade(
 		return nil
 	}
 
-	if *config.DownloadUpgradeTimeoutSeconds > 0 {
-		var cancelFunc context.CancelFunc
-		ctx, cancelFunc = context.WithTimeout(
-			ctx, time.Duration(*config.DownloadUpgradeTimeoutSeconds)*time.Second)
-		defer cancelFunc()
-	}
+	p := config.clientParameters.Get()
+	urls := p.DownloadURLs(parameters.UpgradeDownloadURLs)
+	clientVersionHeader := p.String(parameters.UpgradeDownloadClientVersionHeader)
+	downloadTimeout := p.Duration(parameters.FetchUpgradeTimeout)
+	p = nil
+
+	var cancelFunc context.CancelFunc
+	ctx, cancelFunc = context.WithTimeout(ctx, downloadTimeout)
+	defer cancelFunc()
+
 	// Select tunneled or untunneled configuration
 
-	downloadURL, _, skipVerify := selectDownloadURL(attempt, config.UpgradeDownloadURLs)
+	downloadURL, _, skipVerify := urls.Select(attempt)
 
 	httpClient, err := MakeDownloadHTTPClient(
 		ctx,
@@ -121,7 +125,7 @@ func DownloadUpgrade(
 
 		// Note: if the header is missing, Header.Get returns "" and then
 		// strconv.Atoi returns a parse error.
-		availableClientVersion = response.Header.Get(config.UpgradeDownloadClientVersionHeader)
+		availableClientVersion = response.Header.Get(clientVersionHeader)
 		checkAvailableClientVersion, err := strconv.Atoi(availableClientVersion)
 		if err != nil {
 			// If the header is missing or malformed, we can't determine the available
@@ -132,7 +136,7 @@ func DownloadUpgrade(
 			// download later in the session).
 			NoticeAlert(
 				"failed to download upgrade: invalid %s header value %s: %s",
-				config.UpgradeDownloadClientVersionHeader, availableClientVersion, err)
+				clientVersionHeader, availableClientVersion, err)
 			return nil
 		}
 

+ 10 - 20
psiphon/userAgentPicker.go

@@ -23,7 +23,7 @@ import (
 	"net/http"
 	"sync/atomic"
 
-	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters"
 )
 
 var registeredUserAgentPicker atomic.Value
@@ -40,30 +40,20 @@ func pickUserAgent() string {
 	return ""
 }
 
-// UserAgentIfUnset returns an http.Header object and a boolean
-// representing whether or not its User-Agent header was modified.
-// Any modifications are made to a copy of the original header map
-func UserAgentIfUnset(h http.Header) (http.Header, bool) {
-	var dialHeaders http.Header
+// UserAgentIfUnset selects and sets a User-Agent header if one is not set.
+func UserAgentIfUnset(
+	clientParameters *parameters.ClientParameters, headers http.Header) bool {
 
-	if _, ok := h["User-Agent"]; !ok {
-		dialHeaders = make(map[string][]string)
+	if _, ok := headers["User-Agent"]; !ok {
 
-		if h != nil {
-			for k, v := range h {
-				dialHeaders[k] = make([]string, len(v))
-				copy(dialHeaders[k], v)
-			}
-		}
-
-		if common.FlipCoin() {
-			dialHeaders.Set("User-Agent", pickUserAgent())
+		if clientParameters.Get().WeightedCoinFlip(parameters.PickUserAgentProbability) {
+			headers.Set("User-Agent", pickUserAgent())
 		} else {
-			dialHeaders.Set("User-Agent", "")
+			headers.Set("User-Agent", "")
 		}
 
-		return dialHeaders, true
+		return true
 	}
 
-	return h, false
+	return false
 }

Некоторые файлы не были показаны из-за большого количества измененных файлов