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

Add new tactics to influence protocol usage

- Optionally restrict usage of fronting providers,
  with both server-side and client-side enforcement.

- Optionally hold-off on activating established
  tunnels, to allow other protocols a little more
  time to succeed.
Rod Hynes 4 лет назад
Родитель
Сommit
a51cc9d2ca

+ 20 - 1
psiphon/common/parameters/parameters.go

@@ -231,6 +231,7 @@ const (
 	ReplayAPIRequestPadding                          = "ReplayAPIRequestPadding"
 	ReplayLaterRoundMoveToFrontProbability           = "ReplayLaterRoundMoveToFrontProbability"
 	ReplayRetainFailedProbability                    = "ReplayRetainFailedProbability"
+	ReplayHoldOffTunnel                              = "ReplayHoldOffTunnel"
 	APIRequestUpstreamPaddingMinBytes                = "APIRequestUpstreamPaddingMinBytes"
 	APIRequestUpstreamPaddingMaxBytes                = "APIRequestUpstreamPaddingMaxBytes"
 	APIRequestDownstreamPaddingMinBytes              = "APIRequestDownstreamPaddingMinBytes"
@@ -286,6 +287,14 @@ const (
 	CustomHostNameRegexes                            = "CustomHostNameRegexes"
 	CustomHostNameProbability                        = "CustomHostNameProbability"
 	CustomHostNameLimitProtocols                     = "CustomHostNameLimitProtocols"
+	HoldOffTunnelMinDuration                         = "HoldOffTunnelMinDuration"
+	HoldOffTunnelMaxDuration                         = "HoldOffTunnelMaxDuration"
+	HoldOffTunnelProtocols                           = "HoldOffTunnelProtocols"
+	HoldOffTunnelFrontingProviderIDs                 = "HoldOffTunnelFrontingProviderIDs"
+	HoldOffTunnelProbability                         = "HoldOffTunnelProbability"
+	RestrictFrontingProviderIDs                      = "RestrictFrontingProviderIDs"
+	RestrictFrontingProviderIDsServerProbability     = "RestrictFrontingProviderIDsServerProbability"
+	RestrictFrontingProviderIDsClientProbability     = "RestrictFrontingProviderIDsClientProbability"
 )
 
 const (
@@ -533,6 +542,7 @@ var defaultParameters = map[string]struct {
 	ReplayAPIRequestPadding:                {value: true},
 	ReplayLaterRoundMoveToFrontProbability: {value: 0.0, minimum: 0.0},
 	ReplayRetainFailedProbability:          {value: 0.5, minimum: 0.0},
+	ReplayHoldOffTunnel:                    {value: true},
 
 	APIRequestUpstreamPaddingMinBytes:   {value: 0, minimum: 0},
 	APIRequestUpstreamPaddingMaxBytes:   {value: 1024, minimum: 0},
@@ -600,6 +610,16 @@ var defaultParameters = map[string]struct {
 	CustomHostNameRegexes:        {value: RegexStrings{}},
 	CustomHostNameProbability:    {value: 0.0, minimum: 0.0},
 	CustomHostNameLimitProtocols: {value: protocol.TunnelProtocols{}},
+
+	HoldOffTunnelMinDuration:         {value: time.Duration(0), minimum: time.Duration(0)},
+	HoldOffTunnelMaxDuration:         {value: time.Duration(0), minimum: time.Duration(0)},
+	HoldOffTunnelProtocols:           {value: protocol.TunnelProtocols{}},
+	HoldOffTunnelFrontingProviderIDs: {value: []string{}},
+	HoldOffTunnelProbability:         {value: 0.0, minimum: 0.0},
+
+	RestrictFrontingProviderIDs:                  {value: []string{}},
+	RestrictFrontingProviderIDsServerProbability: {value: 0.0, minimum: 0.0, flags: serverSideOnly},
+	RestrictFrontingProviderIDsClientProbability: {value: 0.0, minimum: 0.0},
 }
 
 // IsServerSideOnly indicates if the parameter specified by name is used
@@ -826,7 +846,6 @@ func (p *Parameters) Set(
 					}
 				}
 			case protocol.LabeledTLSProfiles:
-
 				if skipOnError {
 					newValue = v.PruneInvalid(customTLSProfileNames)
 				} else {

+ 12 - 0
psiphon/common/utils.go

@@ -22,6 +22,7 @@ package common
 import (
 	"bytes"
 	"compress/zlib"
+	"context"
 	"crypto/rand"
 	std_errors "errors"
 	"fmt"
@@ -226,3 +227,14 @@ func SafeParseRequestURI(rawurl string) (*url.URL, error) {
 	}
 	return parsedURL, err
 }
+
+// SleepWithContext returns after the specified duration or once the input ctx
+// is done, whichever is first.
+func SleepWithContext(ctx context.Context, duration time.Duration) {
+	timer := time.NewTimer(duration)
+	defer timer.Stop()
+	select {
+	case <-timer.C:
+	case <-ctx.Done():
+	}
+}

+ 21 - 0
psiphon/common/utils_test.go

@@ -21,11 +21,13 @@ package common
 
 import (
 	"bytes"
+	"context"
 	"encoding/json"
 	"net/url"
 	"reflect"
 	"strings"
 	"testing"
+	"time"
 )
 
 func TestGetStringSlice(t *testing.T) {
@@ -142,3 +144,22 @@ func TestSafeParseRequestURI(t *testing.T) {
 		t.Error("URL in error string")
 	}
 }
+
+func TestSleepWithContext(t *testing.T) {
+
+	start := time.Now()
+	SleepWithContext(context.Background(), 2*time.Millisecond)
+	duration := time.Since(start)
+	if duration/time.Millisecond != 2 {
+		t.Errorf("unexpected duration: %v", duration)
+	}
+
+	start = time.Now()
+	ctx, cancelFunc := context.WithTimeout(context.Background(), 1*time.Millisecond)
+	defer cancelFunc()
+	SleepWithContext(ctx, 2*time.Millisecond)
+	duration = time.Since(start)
+	if duration/time.Millisecond != 1 {
+		t.Errorf("unexpected duration: %v", duration)
+	}
+}

+ 82 - 0
psiphon/config.go

@@ -735,6 +735,19 @@ type Config struct {
 	ConjureDecoyRegistrarMinDelayMilliseconds *int
 	ConjureDecoyRegistrarMaxDelayMilliseconds *int
 
+	// HoldOffTunnelMinDurationMilliseconds and other HoldOffTunnel fields are
+	// for testing purposes.
+	HoldOffTunnelMinDurationMilliseconds *int
+	HoldOffTunnelMaxDurationMilliseconds *int
+	HoldOffTunnelProtocols               []string
+	HoldOffTunnelFrontingProviderIDs     []string
+	HoldOffTunnelProbability             *float64
+
+	// RestrictFrontingProviderIDs and other RestrictFrontingProviderIDs fields
+	// are for testing purposes.
+	RestrictFrontingProviderIDs                  []string
+	RestrictFrontingProviderIDsClientProbability *float64
+
 	// params is the active parameters.Parameters with defaults, config values,
 	// and, optionally, tactics applied.
 	//
@@ -1672,6 +1685,34 @@ func (config *Config) makeConfigParameters() map[string]interface{} {
 		applyParameters[parameters.ConjureDecoyRegistrarMaxDelay] = fmt.Sprintf("%dms", *config.ConjureDecoyRegistrarMaxDelayMilliseconds)
 	}
 
+	if config.HoldOffTunnelMinDurationMilliseconds != nil {
+		applyParameters[parameters.HoldOffTunnelMinDuration] = fmt.Sprintf("%dms", *config.HoldOffTunnelMinDurationMilliseconds)
+	}
+
+	if config.HoldOffTunnelMaxDurationMilliseconds != nil {
+		applyParameters[parameters.HoldOffTunnelMaxDuration] = fmt.Sprintf("%dms", *config.HoldOffTunnelMaxDurationMilliseconds)
+	}
+
+	if len(config.HoldOffTunnelProtocols) > 0 {
+		applyParameters[parameters.HoldOffTunnelProtocols] = protocol.TunnelProtocols(config.HoldOffTunnelProtocols)
+	}
+
+	if len(config.HoldOffTunnelFrontingProviderIDs) > 0 {
+		applyParameters[parameters.HoldOffTunnelFrontingProviderIDs] = config.HoldOffTunnelFrontingProviderIDs
+	}
+
+	if config.HoldOffTunnelProbability != nil {
+		applyParameters[parameters.HoldOffTunnelProbability] = *config.HoldOffTunnelProbability
+	}
+
+	if len(config.RestrictFrontingProviderIDs) > 0 {
+		applyParameters[parameters.RestrictFrontingProviderIDs] = config.RestrictFrontingProviderIDs
+	}
+
+	if config.RestrictFrontingProviderIDsClientProbability != nil {
+		applyParameters[parameters.RestrictFrontingProviderIDsClientProbability] = *config.RestrictFrontingProviderIDsClientProbability
+	}
+
 	// When adding new config dial parameters that may override tactics, also
 	// update setDialParametersHash.
 
@@ -1950,6 +1991,47 @@ func (config *Config) setDialParametersHash() {
 		binary.Write(hash, binary.LittleEndian, int64(*config.ConjureDecoyRegistrarMaxDelayMilliseconds))
 	}
 
+	if config.HoldOffTunnelMinDurationMilliseconds != nil {
+		hash.Write([]byte("HoldOffTunnelMinDurationMilliseconds"))
+		binary.Write(hash, binary.LittleEndian, int64(*config.HoldOffTunnelMinDurationMilliseconds))
+	}
+
+	if config.HoldOffTunnelMaxDurationMilliseconds != nil {
+		hash.Write([]byte("HoldOffTunnelMaxDurationMilliseconds"))
+		binary.Write(hash, binary.LittleEndian, int64(*config.HoldOffTunnelMaxDurationMilliseconds))
+	}
+
+	if len(config.HoldOffTunnelProtocols) > 0 {
+		hash.Write([]byte("HoldOffTunnelProtocols"))
+		for _, protocol := range config.HoldOffTunnelProtocols {
+			hash.Write([]byte(protocol))
+		}
+	}
+
+	if len(config.HoldOffTunnelFrontingProviderIDs) > 0 {
+		hash.Write([]byte("HoldOffTunnelFrontingProviderIDs"))
+		for _, providerID := range config.HoldOffTunnelFrontingProviderIDs {
+			hash.Write([]byte(providerID))
+		}
+	}
+
+	if config.HoldOffTunnelProbability != nil {
+		hash.Write([]byte("HoldOffTunnelProbability"))
+		binary.Write(hash, binary.LittleEndian, *config.HoldOffTunnelProbability)
+	}
+
+	if len(config.RestrictFrontingProviderIDs) > 0 {
+		hash.Write([]byte("RestrictFrontingProviderIDs"))
+		for _, providerID := range config.RestrictFrontingProviderIDs {
+			hash.Write([]byte(providerID))
+		}
+	}
+
+	if config.RestrictFrontingProviderIDsClientProbability != nil {
+		hash.Write([]byte("RestrictFrontingProviderIDsClientProbability"))
+		binary.Write(hash, binary.LittleEndian, *config.RestrictFrontingProviderIDsClientProbability)
+	}
+
 	config.dialParametersHash = hash.Sum(nil)
 }
 

+ 37 - 0
psiphon/dialParameters.go

@@ -128,6 +128,8 @@ type DialParameters struct {
 
 	APIRequestPaddingSeed *prng.Seed
 
+	HoldOffTunnelDuration time.Duration
+
 	DialConnMetrics          common.MetricsSource `json:"-"`
 	ObfuscatedSSHConnMetrics common.MetricsSource `json:"-"`
 
@@ -184,6 +186,7 @@ func MakeDialParameters(
 	replayLivenessTest := p.Bool(parameters.ReplayLivenessTest)
 	replayUserAgent := p.Bool(parameters.ReplayUserAgent)
 	replayAPIRequestPadding := p.Bool(parameters.ReplayAPIRequestPadding)
+	replayHoldOffTunnel := p.Bool(parameters.ReplayHoldOffTunnel)
 
 	// Check for existing dial parameters for this server/network ID.
 
@@ -345,6 +348,20 @@ func MakeDialParameters(
 		dialParams.TunnelProtocol = selectedProtocol
 	}
 
+	// Skip this candidate when the clients tactics restrict usage of the
+	// fronting provider ID. See the corresponding server-side enforcement
+	// comments in server.TacticsListener.accept.
+	if protocol.TunnelProtocolUsesFrontedMeek(dialParams.TunnelProtocol) &&
+		common.Contains(
+			p.Strings(parameters.RestrictFrontingProviderIDs),
+			dialParams.ServerEntry.FrontingProviderID) {
+		if p.WeightedCoinFlip(
+			parameters.RestrictFrontingProviderIDsClientProbability) {
+			return nil, errors.Tracef(
+				"restricted fronting provider ID: %s", dialParams.ServerEntry.FrontingProviderID)
+		}
+	}
+
 	if config.UseUpstreamProxy() &&
 		!protocol.TunnelProtocolSupportsUpstreamProxy(dialParams.TunnelProtocol) {
 
@@ -628,6 +645,26 @@ func MakeDialParameters(
 		}
 	}
 
+	if !isReplay || !replayHoldOffTunnel {
+
+		if common.Contains(
+			p.TunnelProtocols(parameters.HoldOffTunnelProtocols), dialParams.TunnelProtocol) ||
+
+			(protocol.TunnelProtocolUsesFrontedMeek(dialParams.TunnelProtocol) &&
+				common.Contains(
+					p.Strings(parameters.HoldOffTunnelFrontingProviderIDs),
+					dialParams.FrontingProviderID)) {
+
+			if p.WeightedCoinFlip(parameters.HoldOffTunnelProbability) {
+
+				dialParams.HoldOffTunnelDuration = prng.Period(
+					p.Duration(parameters.HoldOffTunnelMinDuration),
+					p.Duration(parameters.HoldOffTunnelMaxDuration))
+			}
+		}
+
+	}
+
 	// Set dial address fields. This portion of configuration is
 	// deterministic, given the parameters established or replayed so far.
 

+ 53 - 2
psiphon/dialParameters_test.go

@@ -76,9 +76,17 @@ func runDialParametersAndReplay(t *testing.T, tunnelProtocol string) {
 		t.Fatalf("error committing configuration file: %s", err)
 	}
 
+	holdOffTunnelProtocols := protocol.TunnelProtocols{protocol.TUNNEL_PROTOCOL_OBFUSCATED_SSH}
+	frontingProviderID := prng.HexString(8)
+
 	applyParameters := make(map[string]interface{})
 	applyParameters[parameters.TransformHostNameProbability] = 1.0
 	applyParameters[parameters.PickUserAgentProbability] = 1.0
+	applyParameters[parameters.HoldOffTunnelMinDuration] = "1ms"
+	applyParameters[parameters.HoldOffTunnelMaxDuration] = "10ms"
+	applyParameters[parameters.HoldOffTunnelProtocols] = holdOffTunnelProtocols
+	applyParameters[parameters.HoldOffTunnelFrontingProviderIDs] = []string{frontingProviderID}
+	applyParameters[parameters.HoldOffTunnelProbability] = 1.0
 	err = clientConfig.SetParameters("tag1", true, applyParameters)
 	if err != nil {
 		t.Fatalf("SetParameters failed: %s", err)
@@ -90,7 +98,7 @@ func runDialParametersAndReplay(t *testing.T, tunnelProtocol string) {
 	}
 	defer CloseDataStore()
 
-	serverEntries := makeMockServerEntries(tunnelProtocol, 100)
+	serverEntries := makeMockServerEntries(tunnelProtocol, frontingProviderID, 100)
 
 	canReplay := func(serverEntry *protocol.ServerEntry, replayProtocol string) bool {
 		return replayProtocol == tunnelProtocol
@@ -204,6 +212,18 @@ func runDialParametersAndReplay(t *testing.T, tunnelProtocol string) {
 		t.Fatalf("missing API request fields")
 	}
 
+	if common.Contains(holdOffTunnelProtocols, tunnelProtocol) ||
+		protocol.TunnelProtocolUsesFrontedMeek(tunnelProtocol) {
+		if dialParams.HoldOffTunnelDuration < 1*time.Millisecond ||
+			dialParams.HoldOffTunnelDuration > 10*time.Millisecond {
+			t.Fatalf("unexpected hold-off duration: %v", dialParams.HoldOffTunnelDuration)
+		}
+	} else {
+		if dialParams.HoldOffTunnelDuration != 0 {
+			t.Fatalf("unexpected hold-off duration: %v", dialParams.HoldOffTunnelDuration)
+		}
+	}
+
 	dialConfig := dialParams.GetDialConfig()
 	if dialConfig.UpstreamProxyErrorCallback == nil {
 		t.Fatalf("missing upstreamProxyErrorCallback")
@@ -418,6 +438,33 @@ func runDialParametersAndReplay(t *testing.T, tunnelProtocol string) {
 		t.Fatalf("unexpected replayed fields")
 	}
 
+	// Test: client-side restrict fronting provider ID
+
+	applyParameters[parameters.RestrictFrontingProviderIDs] = []string{frontingProviderID}
+	applyParameters[parameters.RestrictFrontingProviderIDsClientProbability] = 1.0
+	err = clientConfig.SetParameters("tag4", true, applyParameters)
+	if err != nil {
+		t.Fatalf("SetParameters failed: %s", err)
+	}
+
+	dialParams, err = MakeDialParameters(clientConfig, nil, canReplay, selectProtocol, serverEntries[0], false, 0, 0)
+
+	if protocol.TunnelProtocolUsesFrontedMeek(tunnelProtocol) {
+		if err == nil {
+			t.Fatalf("unexpected MakeDialParameters success")
+		}
+	} else {
+		if err != nil {
+			t.Fatalf("MakeDialParameters failed: %s", err)
+		}
+	}
+
+	applyParameters[parameters.RestrictFrontingProviderIDsClientProbability] = 0.0
+	err = clientConfig.SetParameters("tag5", true, applyParameters)
+	if err != nil {
+		t.Fatalf("SetParameters failed: %s", err)
+	}
+
 	// Test: iterator shuffles
 
 	for i, serverEntry := range serverEntries {
@@ -509,7 +556,10 @@ func runDialParametersAndReplay(t *testing.T, tunnelProtocol string) {
 	}
 }
 
-func makeMockServerEntries(tunnelProtocol string, count int) []*protocol.ServerEntry {
+func makeMockServerEntries(
+	tunnelProtocol string,
+	frontingProviderID string,
+	count int) []*protocol.ServerEntry {
 
 	serverEntries := make([]*protocol.ServerEntry, count)
 
@@ -524,6 +574,7 @@ func makeMockServerEntries(tunnelProtocol string, count int) []*protocol.ServerE
 			MeekServerPort:             6,
 			MeekFrontingHosts:          []string{"www1.example.org", "www2.example.org", "www3.example.org"},
 			MeekFrontingAddressesRegex: "[a-z0-9]{1,64}.example.org",
+			FrontingProviderID:         frontingProviderID,
 			LocalSource:                protocol.SERVER_ENTRY_SOURCE_EMBEDDED,
 			LocalTimestamp:             common.TruncateTimestampToHour(common.GetCurrentTimestamp()),
 		}

+ 15 - 3
psiphon/notice.go

@@ -934,9 +934,21 @@ func NoticeServerAlert(alert protocol.AlertRequest) {
 
 // NoticeBursts reports tunnel data transfer burst metrics.
 func NoticeBursts(diagnosticID string, burstMetrics common.LogFields) {
-	singletonNoticeLogger.outputNotice(
-		"Bursts", noticeIsDiagnostic,
-		append([]interface{}{"diagnosticID", diagnosticID}, listCommonFields(burstMetrics)...)...)
+	if GetEmitNetworkParameters() {
+		singletonNoticeLogger.outputNotice(
+			"Bursts", noticeIsDiagnostic,
+			append([]interface{}{"diagnosticID", diagnosticID}, listCommonFields(burstMetrics)...)...)
+	}
+}
+
+// NoticeHoldOffTunnel reports tunnel hold-offs.
+func NoticeHoldOffTunnel(diagnosticID string, duration time.Duration) {
+	if GetEmitNetworkParameters() {
+		singletonNoticeLogger.outputNotice(
+			"HoldOffTunnel", noticeIsDiagnostic,
+			"diagnosticID", diagnosticID,
+			"duration", duration)
+	}
 }
 
 type repetitiveNoticeState struct {

+ 31 - 2
psiphon/server/config.go

@@ -154,8 +154,8 @@ type Config struct {
 	// protocols include:
 	// "SSH", "OSSH", "UNFRONTED-MEEK-OSSH", "UNFRONTED-MEEK-HTTPS-OSSH",
 	// "UNFRONTED-MEEK-SESSION-TICKET-OSSH", "FRONTED-MEEK-OSSH",
-	// ""FRONTED-MEEK-QUIC-OSSH" FRONTED-MEEK-HTTP-OSSH", "QUIC-OSSH",
-	// ""MARIONETTE-OSSH", and TAPDANCE-OSSH".
+	// "FRONTED-MEEK-QUIC-OSSH", "FRONTED-MEEK-HTTP-OSSH", "QUIC-OSSH",
+	// "MARIONETTE-OSSH", "TAPDANCE-OSSH", abd "CONJURE-OSSH".
 	//
 	// In the case of "MARIONETTE-OSSH" the port value is ignored and must be
 	// set to 0. The port value specified in the Marionette format is used.
@@ -420,6 +420,7 @@ type Config struct {
 	periodicGarbageCollection                      time.Duration
 	stopEstablishTunnelsEstablishedClientThreshold int
 	dumpProfilesOnStopEstablishTunnelsDone         int32
+	frontingProviderID                             string
 }
 
 // GetLogFileReopenConfig gets the reopen retries, and create/mode inputs for
@@ -486,6 +487,12 @@ func (config *Config) GetOwnEncodedServerEntry(serverEntryTag string) (string, b
 	return serverEntry, ok
 }
 
+// GetFrontingProviderID returns the fronting provider ID associated with the
+// server's fronted protocol(s).
+func (config *Config) GetFrontingProviderID() string {
+	return config.frontingProviderID
+}
+
 // LoadConfig loads and validates a JSON encoded server config.
 func LoadConfig(configJSON []byte) (*Config, error) {
 
@@ -625,6 +632,28 @@ func LoadConfig(configJSON []byte) (*Config, error) {
 			"AccessControlVerificationKeyRing is invalid: %s", err)
 	}
 
+	// Limitation: the following is a shortcut which extracts the server's
+	// fronting provider ID from the server's OwnEncodedServerEntries. This logic
+	// assumes a server has only one fronting provider. In principle, it's
+	// possible for server with multiple server entries to run multiple fronted
+	// protocols, each with a different fronting provider ID.
+	//
+	// TODO: add an explicit parameter mapping tunnel protocol ports to fronting
+	// provider IDs.
+
+	for _, encodedServerEntry := range config.OwnEncodedServerEntries {
+		serverEntry, err := protocol.DecodeServerEntry(encodedServerEntry, "", "")
+		if err != nil {
+			return nil, errors.Tracef(
+				"protocol.DecodeServerEntry failed: %s", err)
+		}
+		if config.frontingProviderID == "" {
+			config.frontingProviderID = serverEntry.FrontingProviderID
+		} else if config.frontingProviderID != serverEntry.FrontingProviderID {
+			return nil, errors.Tracef("unsupported multiple FrontingProviderID values")
+		}
+	}
+
 	return &config, nil
 }
 

+ 41 - 0
psiphon/server/listener.go

@@ -25,6 +25,7 @@ import (
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/fragmentor"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
 )
@@ -57,6 +58,18 @@ func NewTacticsListener(
 // Accept calls the underlying listener's Accept, and then applies server-side
 // tactics to new connections.
 func (listener *TacticsListener) Accept() (net.Conn, error) {
+	for {
+		// accept may discard a successfully accepted conn. In that case, accept
+		// returns nil, nil; call accept until either the conn or err is not nil.
+		conn, err := listener.accept()
+		if conn != nil || err != nil {
+			// Don't modify error from net.Listener
+			return conn, err
+		}
+	}
+}
+
+func (listener *TacticsListener) accept() (net.Conn, error) {
 
 	conn, err := listener.Listener.Accept()
 	if err != nil {
@@ -77,6 +90,34 @@ func (listener *TacticsListener) Accept() (net.Conn, error) {
 		return conn, nil
 	}
 
+	// Disconnect immediately if the clients tactics restricts usage of the
+	// fronting provider ID. The probability may be used to influence usage of a
+	// given fronting provider; but when only that provider works for a given
+	// client, and the probability is less than 1.0, the client can retry until
+	// it gets a successful coin flip.
+	//
+	// Clients will also skip candidates with restricted fronting provider IDs.
+	// The client-side probability, RestrictFrontingProviderIDsClientProbability,
+	// is applied independently of the server-side coin flip here.
+	//
+	//
+	// At this stage, GeoIP tactics filters are active, but handshake API
+	// parameters are not.
+	//
+	// See the comment in server.LoadConfig regarding fronting provider ID
+	// limitations.
+
+	if protocol.TunnelProtocolUsesFrontedMeek(listener.tunnelProtocol) &&
+		common.Contains(
+			p.Strings(parameters.RestrictFrontingProviderIDs),
+			listener.support.Config.GetFrontingProviderID()) {
+		if p.WeightedCoinFlip(
+			parameters.RestrictFrontingProviderIDsServerProbability) {
+			conn.Close()
+			return nil, nil
+		}
+	}
+
 	// Server-side fragmentation may be synchronized with client-side in two ways.
 	//
 	// In the OSSH case, replay is always activated and it is seeded using the

+ 50 - 7
psiphon/server/listener_test.go

@@ -28,13 +28,16 @@ import (
 	"time"
 
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/fragmentor"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/tactics"
 )
 
 func TestListener(t *testing.T) {
 
-	tunnelProtocol := protocol.TUNNEL_PROTOCOL_OBFUSCATED_SSH
+	tunnelProtocol := protocol.TUNNEL_PROTOCOL_FRONTED_MEEK
+
+	frontingProviderID := prng.HexString(8)
 
 	tacticsConfigJSONFormat := `
     {
@@ -54,14 +57,25 @@ func TestListener(t *testing.T) {
           },
           "Tactics" : {
             "Parameters" : {
-              "LimitTunnelProtocols" : ["%s"],
               "FragmentorDownstreamLimitProtocols" : ["%s"],
               "FragmentorDownstreamProbability" : 1.0,
               "FragmentorDownstreamMinTotalBytes" : 1,
               "FragmentorDownstreamMaxTotalBytes" : 1,
               "FragmentorDownstreamMinWriteBytes" : 1,
-              "FragmentorDownstreamMaxWriteBytes" : 1,
-              "FragmentorDownstreamLimitProtocols" : ["OSSH"]
+              "FragmentorDownstreamMaxWriteBytes" : 1
+            }
+          }
+        },
+        {
+          "Filter" : {
+            "Regions": ["R3"],
+            "ISPs": ["I3"],
+            "Cities": ["C3"]
+          },
+          "Tactics" : {
+            "Parameters" : {
+              "RestrictFrontingProviderIDs" : ["%s"],
+              "RestrictFrontingProviderIDsServerProbability" : 1.0
             }
           }
         }
@@ -78,7 +92,7 @@ func TestListener(t *testing.T) {
 	tacticsConfigJSON := fmt.Sprintf(
 		tacticsConfigJSONFormat,
 		tacticsRequestPublicKey, tacticsRequestPrivateKey, tacticsRequestObfuscatedKey,
-		tunnelProtocol, tunnelProtocol)
+		tunnelProtocol, frontingProviderID)
 
 	tacticsConfigFilename := filepath.Join(testDataDirName, "tactics_config.json")
 
@@ -108,31 +122,54 @@ func TestListener(t *testing.T) {
 	listenerUnfragmentedGeoIPWrongCity := func(string) GeoIPData {
 		return GeoIPData{Country: "R1", ISP: "I1", City: "C2"}
 	}
+	listenerRestrictedFrontingProviderIDGeoIP := func(string) GeoIPData {
+		return GeoIPData{Country: "R3", ISP: "I3", City: "C3"}
+	}
+	listenerUnrestrictedFrontingProviderIDWrongRegion := func(string) GeoIPData {
+		return GeoIPData{Country: "R2", ISP: "I3", City: "C3"}
+	}
 
 	listenerTestCases := []struct {
 		description      string
 		geoIPLookup      func(string) GeoIPData
 		expectFragmentor bool
+		expectConnection bool
 	}{
 		{
 			"fragmented",
 			listenerFragmentedGeoIP,
 			true,
+			true,
 		},
 		{
 			"unfragmented-region",
 			listenerUnfragmentedGeoIPWrongRegion,
 			false,
+			true,
 		},
 		{
 			"unfragmented-ISP",
 			listenerUnfragmentedGeoIPWrongISP,
 			false,
+			true,
 		},
 		{
 			"unfragmented-city",
 			listenerUnfragmentedGeoIPWrongCity,
 			false,
+			true,
+		},
+		{
+			"restricted",
+			listenerRestrictedFrontingProviderIDGeoIP,
+			false,
+			false,
+		},
+		{
+			"unrestricted-region",
+			listenerUnrestrictedFrontingProviderIDWrongRegion,
+			false,
+			true,
 		},
 	}
 
@@ -145,6 +182,7 @@ func TestListener(t *testing.T) {
 			}
 
 			support := &SupportServices{
+				Config:        &Config{frontingProviderID: frontingProviderID},
 				TacticsServer: tacticsServer,
 			}
 			support.ReplayCache = NewReplayCache(support)
@@ -172,11 +210,14 @@ func TestListener(t *testing.T) {
 				}
 			}()
 
-			timer := time.NewTimer(3 * time.Second)
+			timer := time.NewTimer(1 * time.Second)
 			defer timer.Stop()
 
 			select {
 			case serverConn := <-result:
+				if !testCase.expectConnection {
+					t.Fatalf("unexpected accepted connection")
+				}
 				_, isFragmentor := serverConn.(*fragmentor.Conn)
 				if testCase.expectFragmentor && !isFragmentor {
 					t.Fatalf("unexpected non-fragmentor: %T", serverConn)
@@ -185,7 +226,9 @@ func TestListener(t *testing.T) {
 				}
 				serverConn.Close()
 			case <-timer.C:
-				t.Fatalf("timeout before expected accepted connection")
+				if testCase.expectConnection {
+					t.Fatalf("timeout before expected accepted connection")
+				}
 			}
 
 			clientConn.Close()

+ 11 - 2
psiphon/tunnel.go

@@ -695,8 +695,6 @@ func dialTunnel(
 	// parameters are cleared, no longer to be retried, if the tunnel fails to
 	// connect.
 	//
-	//
-	//
 	// Limitation: dials that fail to connect due to the server being in a
 	// load-limiting state are not distinguished and excepted from this
 	// logic.
@@ -1158,6 +1156,17 @@ func dialTunnel(
 
 	cleanupConn = nil
 
+	// When configured to do so, hold-off on activating this tunnel. This allows
+	// some extra time for slower but less resource intensive protocols to
+	// establish tunnels. By holding off post-connect, the client has this
+	// established tunnel ready to activate in case other protocols fail to
+	// establish. This hold-off phase continues to consume one connection worker.
+
+	if dialParams.HoldOffTunnelDuration > 0 {
+		NoticeHoldOffTunnel(dialParams.ServerEntry.GetDiagnosticID(), dialParams.HoldOffTunnelDuration)
+		common.SleepWithContext(ctx, dialParams.HoldOffTunnelDuration)
+	}
+
 	// Note: dialConn may be used to close the underlying network connection
 	// but should not be used to perform I/O as that would interfere with SSH
 	// (and also bypasses throttling).