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

Merge branch 'master' into utls

Rod Hynes 7 лет назад
Родитель
Сommit
fdf953d7af

+ 3 - 1
.travis.yml

@@ -1,7 +1,7 @@
 language: go
 sudo: required
 go:
-- 1.9.5
+- 1.9.6
 addons:
   apt_packages:
     - libx11-dev
@@ -12,6 +12,7 @@ script:
 - cd psiphon
 - go test -race -v ./common
 - go test -race -v ./common/accesscontrol
+- go test -race -v ./common/obfuscator
 - go test -race -v ./common/osl
 - go test -race -v ./common/parameters
 - go test -race -v ./common/protocol
@@ -27,6 +28,7 @@ script:
 - go test -race -v
 - 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=obfuscator.coverprofile ./common/obfuscator
 - 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

+ 1 - 1
ConsoleClient/Dockerfile

@@ -22,7 +22,7 @@ RUN apt-get update -y && apt-get install -y --no-install-recommends \
   && rm -rf /var/lib/apt/lists/*
 
 # Install Go.
-ENV GOVERSION=go1.9.5 GOROOT=/usr/local/go GOPATH=/go PATH=$PATH:/usr/local/go/bin:/go/bin CGO_ENABLED=1
+ENV GOVERSION=go1.9.6 GOROOT=/usr/local/go GOPATH=/go PATH=$PATH:/usr/local/go/bin:/go/bin CGO_ENABLED=1
 
 RUN curl -L https://storage.googleapis.com/golang/$GOVERSION.linux-amd64.tar.gz -o /tmp/go.tar.gz \
    && tar -C /usr/local -xzf /tmp/go.tar.gz \

+ 1 - 1
ConsoleClient/main.go

@@ -241,7 +241,7 @@ func main() {
 			}
 			// Since embedded server list entries may become stale, they will not
 			// overwrite existing stored entries for the same server.
-			err = psiphon.StoreServerEntries(serverEntries, false)
+			err = psiphon.StoreServerEntries(config, serverEntries, false)
 			if err != nil {
 				psiphon.NoticeError("error storing embedded server entry list data: %s", err)
 				return

+ 1 - 1
MobileLibrary/Android/Dockerfile

@@ -19,7 +19,7 @@ RUN apt-get update -y && apt-get install -y --no-install-recommends \
   && rm -rf /var/lib/apt/lists/*
 
 # Install Go.
-ENV GOVERSION=go1.9.5 GOROOT=/usr/local/go GOPATH=/go PATH=$PATH:/usr/local/go/bin:/go/bin CGO_ENABLED=1
+ENV GOVERSION=go1.9.6 GOROOT=/usr/local/go GOPATH=/go PATH=$PATH:/usr/local/go/bin:/go/bin CGO_ENABLED=1
 
 RUN curl -L https://storage.googleapis.com/golang/$GOVERSION.linux-amd64.tar.gz -o /tmp/go.tar.gz \
   && tar -C /usr/local -xzf /tmp/go.tar.gz \

+ 4 - 1
MobileLibrary/iOS/build-psiphon-framework.sh

@@ -15,7 +15,7 @@ fi
 set -x -u -e
 
 # Modify this value as we use newer Go versions.
-GO_VERSION_REQUIRED="1.9.5"
+GO_VERSION_REQUIRED="1.9.6"
 
 # Reset the PATH to macOS default. This is mainly so we don't execute the wrong
 # gomobile executable.
@@ -137,6 +137,9 @@ fi
 # gomobile bind
 #
 
+# Ensure BUILD* variables reflect the tunnel-core repo
+cd ${TUNNEL_CORE_SRC_DIR}
+
 BUILDDATE=$(date +%Y-%m-%dT%H:%M:%S%z)
 BUILDREPO=$(git config --get remote.origin.url)
 BUILDREV=$(git rev-parse --short HEAD)

+ 9 - 3
MobileLibrary/psi/psi.go

@@ -132,7 +132,10 @@ func Start(
 	}
 
 	// Stores list of server entries.
-	err = storeServerEntries(embeddedServerEntryListFilename, embeddedServerEntryList)
+	err = storeServerEntries(
+		config,
+		embeddedServerEntryListFilename,
+		embeddedServerEntryList)
 	if err != nil {
 		return err
 	}
@@ -222,7 +225,9 @@ func GetPacketTunnelDNSResolverIPv6Address() string {
 
 // Helper function to store a list of server entries.
 // if embeddedServerEntryListFilename is not empty, embeddedServerEntryList will be ignored.
-func storeServerEntries(embeddedServerEntryListFilename, embeddedServerEntryList string) error {
+func storeServerEntries(
+	config *psiphon.Config,
+	embeddedServerEntryListFilename, embeddedServerEntryList string) error {
 
 	if embeddedServerEntryListFilename != "" {
 
@@ -233,6 +238,7 @@ func storeServerEntries(embeddedServerEntryListFilename, embeddedServerEntryList
 		defer file.Close()
 
 		err = psiphon.StreamingStoreServerEntries(
+			config,
 			protocol.NewStreamingServerEntryDecoder(
 				file,
 				common.GetCurrentTimestamp(),
@@ -251,7 +257,7 @@ func storeServerEntries(embeddedServerEntryListFilename, embeddedServerEntryList
 		if err != nil {
 			return fmt.Errorf("error decoding embedded server list: %s", err)
 		}
-		err = psiphon.StoreServerEntries(serverEntries, false)
+		err = psiphon.StoreServerEntries(config, serverEntries, false)
 		if err != nil {
 			return fmt.Errorf("error storing embedded server list: %s", err)
 		}

+ 1 - 1
Server/Dockerfile-binary-builder

@@ -1,6 +1,6 @@
 FROM alpine:3.4
 
-ENV GOLANG_VERSION 1.9.5
+ENV GOLANG_VERSION 1.9.6
 ENV GOLANG_SRC_URL https://golang.org/dl/go$GOLANG_VERSION.src.tar.gz
 
 RUN set -ex \

+ 62 - 0
psiphon/common/crypto/ssh/handshake.go

@@ -12,6 +12,8 @@ import (
 	"log"
 	"net"
 	"sync"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
 )
 
 // debugHandshake, if set, prints messages sent and received.  Key
@@ -457,6 +459,66 @@ func (t *handshakeTransport) sendKexInit() error {
 	} else {
 		msg.ServerHostKeyAlgos = t.hostKeyAlgorithms
 	}
+
+	// PSIPHON
+	// =======
+	//
+	// Randomize KEX. The offered algorithms are shuffled and
+	// truncated (longer lists are selected with higher
+	// probability).
+	//
+	// As the client and server have the same set of algorithms,
+	// almost any combination is expected to be workable.
+	//
+	// The compression algorithm is not actually supported, but
+	// the server will not negotiate it.
+	//
+	// common.MakeSecureRandomPerm and common.FlipCoin are
+	// unlikely to fail; if they do, proceed with the standard
+	// ordering, full lists, and standard compressions.
+	//
+	// The "t.remoteAddr != nil" condition should be true only
+	// for clients.
+	//
+	if t.remoteAddr != nil {
+
+		transform := func(list []string) []string {
+
+			newList := make([]string, len(list))
+			perm, err := common.MakeSecureRandomPerm(len(list))
+			if err == nil {
+				for i, j := range perm {
+					newList[j] = list[i]
+				}
+			}
+
+			cut := len(newList)
+			for ; cut > 1; cut-- {
+				if !common.FlipCoin() {
+					break
+				}
+			}
+
+			return newList[:cut]
+		}
+
+		msg.KexAlgos = transform(t.config.KeyExchanges)
+		ciphers := transform(t.config.Ciphers)
+		msg.CiphersClientServer = ciphers
+		msg.CiphersServerClient = ciphers
+		MACs := transform(t.config.MACs)
+		msg.MACsClientServer = MACs
+		msg.MACsServerClient = MACs
+
+		// Offer "zlib@openssh.com", which is offered by OpenSSH.
+		// Since server only supports "none", must always offer "none"
+		if common.FlipCoin() {
+			compressions := []string{"none", "zlib@openssh.com"}
+			msg.CompressionClientServer = compressions
+			msg.CompressionServerClient = compressions
+		}
+	}
+
 	packet := Marshal(msg)
 
 	// writePacket destroys the contents, so save a copy.

+ 31 - 29
psiphon/common/obfuscatedSshConn.go → psiphon/common/obfuscator/obfuscatedSshConn.go

@@ -17,7 +17,7 @@
  *
  */
 
-package common
+package obfuscator
 
 import (
 	"bytes"
@@ -25,6 +25,8 @@ import (
 	"errors"
 	"io"
 	"net"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
 )
 
 const (
@@ -116,7 +118,7 @@ func NewObfuscatedSshConn(
 	if mode == OBFUSCATION_CONN_MODE_CLIENT {
 		obfuscator, err = NewClientObfuscator(&ObfuscatorConfig{Keyword: obfuscationKeyword})
 		if err != nil {
-			return nil, ContextError(err)
+			return nil, common.ContextError(err)
 		}
 		readDeobfuscate = obfuscator.ObfuscateServerToClient
 		writeObfuscate = obfuscator.ObfuscateClientToServer
@@ -127,7 +129,7 @@ func NewObfuscatedSshConn(
 			conn, &ObfuscatorConfig{Keyword: obfuscationKeyword})
 		if err != nil {
 			// TODO: readForver() equivalent
-			return nil, ContextError(err)
+			return nil, common.ContextError(err)
 		}
 		readDeobfuscate = obfuscator.ObfuscateClientToServer
 		writeObfuscate = obfuscator.ObfuscateServerToClient
@@ -156,7 +158,7 @@ func (conn *ObfuscatedSshConn) Read(buffer []byte) (int, error) {
 	}
 	n, err := conn.readAndTransform(buffer)
 	if err != nil {
-		err = ContextError(err)
+		err = common.ContextError(err)
 	}
 	return n, err
 }
@@ -169,7 +171,7 @@ func (conn *ObfuscatedSshConn) Write(buffer []byte) (int, error) {
 	}
 	err := conn.transformAndWrite(buffer)
 	if err != nil {
-		return 0, ContextError(err)
+		return 0, common.ContextError(err)
 	}
 	// Reports that we wrote all the bytes
 	// (although we may have buffered some or all)
@@ -227,7 +229,7 @@ func (conn *ObfuscatedSshConn) readAndTransform(buffer []byte) (int, error) {
 				err := readSshIdentificationLine(
 					conn.Conn, conn.readDeobfuscate, conn.readBuffer)
 				if err != nil {
-					return 0, ContextError(err)
+					return 0, common.ContextError(err)
 				}
 				if bytes.HasPrefix(conn.readBuffer.Bytes(), []byte("SSH-")) {
 					break
@@ -243,7 +245,7 @@ func (conn *ObfuscatedSshConn) readAndTransform(buffer []byte) (int, error) {
 			isMsgNewKeys, err := readSshPacket(
 				conn.Conn, conn.readDeobfuscate, conn.readBuffer)
 			if err != nil {
-				return 0, ContextError(err)
+				return 0, common.ContextError(err)
 			}
 			if isMsgNewKeys {
 				nextState = OBFUSCATION_READ_STATE_FLUSH
@@ -254,7 +256,7 @@ func (conn *ObfuscatedSshConn) readAndTransform(buffer []byte) (int, error) {
 		nextState = OBFUSCATION_READ_STATE_FINISHED
 
 	case OBFUSCATION_READ_STATE_FINISHED:
-		return 0, ContextError(errors.New("invalid read state"))
+		return 0, common.ContextError(errors.New("invalid read state"))
 	}
 
 	n, err := conn.readBuffer.Read(buffer)
@@ -262,7 +264,7 @@ func (conn *ObfuscatedSshConn) readAndTransform(buffer []byte) (int, error) {
 		err = nil
 	}
 	if err != nil {
-		return n, ContextError(err)
+		return n, common.ContextError(err)
 	}
 	if conn.readBuffer.Len() == 0 {
 		conn.readState = nextState
@@ -321,18 +323,18 @@ func (conn *ObfuscatedSshConn) transformAndWrite(buffer []byte) error {
 	if conn.writeState == OBFUSCATION_WRITE_STATE_CLIENT_SEND_SEED_MESSAGE {
 		_, err := conn.Conn.Write(conn.obfuscator.SendSeedMessage())
 		if err != nil {
-			return ContextError(err)
+			return common.ContextError(err)
 		}
 		conn.writeState = OBFUSCATION_WRITE_STATE_IDENTIFICATION_LINE
 	} else if conn.writeState == OBFUSCATION_WRITE_STATE_SERVER_SEND_IDENTIFICATION_LINE_PADDING {
 		padding, err := makeServerIdentificationLinePadding()
 		if err != nil {
-			return ContextError(err)
+			return common.ContextError(err)
 		}
 		conn.writeObfuscate(padding)
 		_, err = conn.Conn.Write(padding)
 		if err != nil {
-			return ContextError(err)
+			return common.ContextError(err)
 		}
 		conn.writeState = OBFUSCATION_WRITE_STATE_IDENTIFICATION_LINE
 	}
@@ -357,14 +359,14 @@ func (conn *ObfuscatedSshConn) transformAndWrite(buffer []byte) error {
 		hasMsgNewKeys, err := extractSshPackets(
 			conn.writeBuffer, conn.transformBuffer)
 		if err != nil {
-			return ContextError(err)
+			return common.ContextError(err)
 		}
 		if hasMsgNewKeys {
 			conn.writeState = OBFUSCATION_WRITE_STATE_FINISHED
 		}
 
 	case OBFUSCATION_WRITE_STATE_FINISHED:
-		return ContextError(errors.New("invalid write state"))
+		return common.ContextError(errors.New("invalid write state"))
 	}
 
 	if conn.transformBuffer.Len() > 0 {
@@ -372,7 +374,7 @@ func (conn *ObfuscatedSshConn) transformAndWrite(buffer []byte) error {
 		conn.writeObfuscate(sendData)
 		_, err := conn.Conn.Write(sendData)
 		if err != nil {
-			return ContextError(err)
+			return common.ContextError(err)
 		}
 	}
 
@@ -381,7 +383,7 @@ func (conn *ObfuscatedSshConn) transformAndWrite(buffer []byte) error {
 			// After SSH_MSG_NEWKEYS, any remaining bytes are un-obfuscated
 			_, err := conn.Conn.Write(conn.writeBuffer.Bytes())
 			if err != nil {
-				return ContextError(err)
+				return common.ContextError(err)
 			}
 		}
 		// The buffer memory is no longer used
@@ -403,7 +405,7 @@ func readSshIdentificationLine(
 	for i := 0; i < SSH_MAX_SERVER_LINE_LENGTH; i++ {
 		_, err := io.ReadFull(conn, oneByte[:])
 		if err != nil {
-			return ContextError(err)
+			return common.ContextError(err)
 		}
 		deobfuscate(oneByte[:])
 		readBuffer.WriteByte(oneByte[0])
@@ -413,7 +415,7 @@ func readSshIdentificationLine(
 		}
 	}
 	if !validLine {
-		return ContextError(errors.New("invalid identification line"))
+		return common.ContextError(errors.New("invalid identification line"))
 	}
 	return nil
 }
@@ -431,7 +433,7 @@ func readSshPacket(
 		err = errors.New("unxpected number of bytes read")
 	}
 	if err != nil {
-		return false, ContextError(err)
+		return false, common.ContextError(err)
 	}
 
 	prefix := readBuffer.Bytes()[prefixOffset : prefixOffset+SSH_PACKET_PREFIX_LENGTH]
@@ -439,7 +441,7 @@ func readSshPacket(
 
 	_, _, payloadLength, messageLength, err := getSshPacketPrefix(prefix)
 	if err != nil {
-		return false, ContextError(err)
+		return false, common.ContextError(err)
 	}
 
 	remainingReadLength := messageLength - SSH_PACKET_PREFIX_LENGTH
@@ -449,7 +451,7 @@ func readSshPacket(
 		err = errors.New("unxpected number of bytes read")
 	}
 	if err != nil {
-		return false, ContextError(err)
+		return false, common.ContextError(err)
 	}
 
 	remainingBytes := readBuffer.Bytes()[prefixOffset+SSH_PACKET_PREFIX_LENGTH:]
@@ -468,9 +470,9 @@ func readSshPacket(
 // From the original patch to sshd.c:
 // https://bitbucket.org/psiphon/psiphon-circumvention-system/commits/f40865ce624b680be840dc2432283c8137bd896d
 func makeServerIdentificationLinePadding() ([]byte, error) {
-	paddingLength, err := MakeSecureRandomInt(OBFUSCATE_MAX_PADDING - 2) // 2 = CRLF
+	paddingLength, err := common.MakeSecureRandomInt(OBFUSCATE_MAX_PADDING - 2) // 2 = CRLF
 	if err != nil {
-		return nil, ContextError(err)
+		return nil, common.ContextError(err)
 	}
 	paddingLength += 2
 	padding := make([]byte, paddingLength)
@@ -518,7 +520,7 @@ func extractSshPackets(writeBuffer, transformBuffer *bytes.Buffer) (bool, error)
 		packetLength, paddingLength, payloadLength, messageLength, err := getSshPacketPrefix(
 			writeBuffer.Bytes()[:SSH_PACKET_PREFIX_LENGTH])
 		if err != nil {
-			return false, ContextError(err)
+			return false, common.ContextError(err)
 		}
 
 		if writeBuffer.Len() < messageLength {
@@ -545,14 +547,14 @@ func extractSshPackets(writeBuffer, transformBuffer *bytes.Buffer) (bool, error)
 		if possiblePaddings > 0 {
 
 			// selectedPadding is integer in range [0, possiblePaddings)
-			selectedPadding, err := MakeSecureRandomInt(possiblePaddings)
+			selectedPadding, err := common.MakeSecureRandomInt(possiblePaddings)
 			if err != nil {
-				return false, ContextError(err)
+				return false, common.ContextError(err)
 			}
 			extraPaddingLength := selectedPadding * SSH_PADDING_MULTIPLE
-			extraPadding, err := MakeSecureRandomBytes(extraPaddingLength)
+			extraPadding, err := common.MakeSecureRandomBytes(extraPaddingLength)
 			if err != nil {
-				return false, ContextError(err)
+				return false, common.ContextError(err)
 			}
 
 			setSshPacketPrefix(
@@ -571,7 +573,7 @@ func getSshPacketPrefix(buffer []byte) (int, int, int, int, error) {
 	packetLength := int(binary.BigEndian.Uint32(buffer[0 : SSH_PACKET_PREFIX_LENGTH-1]))
 
 	if packetLength < 1 || packetLength > SSH_MAX_PACKET_LENGTH {
-		return 0, 0, 0, 0, ContextError(errors.New("invalid ssh packet length"))
+		return 0, 0, 0, 0, common.ContextError(errors.New("invalid ssh packet length"))
 	}
 
 	paddingLength := int(buffer[SSH_PACKET_PREFIX_LENGTH-1])

+ 29 - 27
psiphon/common/obfuscator.go → psiphon/common/obfuscator/obfuscator.go

@@ -17,7 +17,7 @@
  *
  */
 
-package common
+package obfuscator
 
 import (
 	"bytes"
@@ -26,6 +26,8 @@ import (
 	"encoding/binary"
 	"errors"
 	"io"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
 )
 
 const (
@@ -58,14 +60,14 @@ type ObfuscatorConfig struct {
 func NewClientObfuscator(
 	config *ObfuscatorConfig) (obfuscator *Obfuscator, err error) {
 
-	seed, err := MakeSecureRandomBytes(OBFUSCATE_SEED_LENGTH)
+	seed, err := common.MakeSecureRandomBytes(OBFUSCATE_SEED_LENGTH)
 	if err != nil {
-		return nil, ContextError(err)
+		return nil, common.ContextError(err)
 	}
 
 	clientToServerCipher, serverToClientCipher, err := initObfuscatorCiphers(seed, config)
 	if err != nil {
-		return nil, ContextError(err)
+		return nil, common.ContextError(err)
 	}
 
 	maxPadding := OBFUSCATE_MAX_PADDING
@@ -75,7 +77,7 @@ func NewClientObfuscator(
 
 	seedMessage, err := makeSeedMessage(maxPadding, seed, clientToServerCipher)
 	if err != nil {
-		return nil, ContextError(err)
+		return nil, common.ContextError(err)
 	}
 
 	return &Obfuscator{
@@ -92,7 +94,7 @@ func NewServerObfuscator(
 	clientToServerCipher, serverToClientCipher, err := readSeedMessage(
 		clientReader, config)
 	if err != nil {
-		return nil, ContextError(err)
+		return nil, common.ContextError(err)
 	}
 
 	return &Obfuscator{
@@ -123,22 +125,22 @@ func initObfuscatorCiphers(
 
 	clientToServerKey, err := deriveKey(seed, []byte(config.Keyword), []byte(OBFUSCATE_CLIENT_TO_SERVER_IV))
 	if err != nil {
-		return nil, nil, ContextError(err)
+		return nil, nil, common.ContextError(err)
 	}
 
 	serverToClientKey, err := deriveKey(seed, []byte(config.Keyword), []byte(OBFUSCATE_SERVER_TO_CLIENT_IV))
 	if err != nil {
-		return nil, nil, ContextError(err)
+		return nil, nil, common.ContextError(err)
 	}
 
 	clientToServerCipher, err := rc4.NewCipher(clientToServerKey)
 	if err != nil {
-		return nil, nil, ContextError(err)
+		return nil, nil, common.ContextError(err)
 	}
 
 	serverToClientCipher, err := rc4.NewCipher(serverToClientKey)
 	if err != nil {
-		return nil, nil, ContextError(err)
+		return nil, nil, common.ContextError(err)
 	}
 
 	return clientToServerCipher, serverToClientCipher, nil
@@ -156,37 +158,37 @@ func deriveKey(seed, keyword, iv []byte) ([]byte, error) {
 		digest = h.Sum(nil)
 	}
 	if len(digest) < OBFUSCATE_KEY_LENGTH {
-		return nil, ContextError(errors.New("insufficient bytes for obfuscation key"))
+		return nil, common.ContextError(errors.New("insufficient bytes for obfuscation key"))
 	}
 	return digest[0:OBFUSCATE_KEY_LENGTH], nil
 }
 
 func makeSeedMessage(maxPadding int, seed []byte, clientToServerCipher *rc4.Cipher) ([]byte, error) {
 	// paddingLength is integer in range [0, maxPadding]
-	paddingLength, err := MakeSecureRandomInt(maxPadding + 1)
+	paddingLength, err := common.MakeSecureRandomInt(maxPadding + 1)
 	if err != nil {
-		return nil, ContextError(err)
+		return nil, common.ContextError(err)
 	}
-	padding, err := MakeSecureRandomBytes(paddingLength)
+	padding, err := common.MakeSecureRandomBytes(paddingLength)
 	if err != nil {
-		return nil, ContextError(err)
+		return nil, common.ContextError(err)
 	}
 	buffer := new(bytes.Buffer)
 	err = binary.Write(buffer, binary.BigEndian, seed)
 	if err != nil {
-		return nil, ContextError(err)
+		return nil, common.ContextError(err)
 	}
 	err = binary.Write(buffer, binary.BigEndian, uint32(OBFUSCATE_MAGIC_VALUE))
 	if err != nil {
-		return nil, ContextError(err)
+		return nil, common.ContextError(err)
 	}
 	err = binary.Write(buffer, binary.BigEndian, uint32(paddingLength))
 	if err != nil {
-		return nil, ContextError(err)
+		return nil, common.ContextError(err)
 	}
 	err = binary.Write(buffer, binary.BigEndian, padding)
 	if err != nil {
-		return nil, ContextError(err)
+		return nil, common.ContextError(err)
 	}
 	seedMessage := buffer.Bytes()
 	clientToServerCipher.XORKeyStream(seedMessage[len(seed):], seedMessage[len(seed):])
@@ -199,18 +201,18 @@ func readSeedMessage(
 	seed := make([]byte, OBFUSCATE_SEED_LENGTH)
 	_, err := io.ReadFull(clientReader, seed)
 	if err != nil {
-		return nil, nil, ContextError(err)
+		return nil, nil, common.ContextError(err)
 	}
 
 	clientToServerCipher, serverToClientCipher, err := initObfuscatorCiphers(seed, config)
 	if err != nil {
-		return nil, nil, ContextError(err)
+		return nil, nil, common.ContextError(err)
 	}
 
 	fixedLengthFields := make([]byte, 8) // 4 bytes each for magic value and padding length
 	_, err = io.ReadFull(clientReader, fixedLengthFields)
 	if err != nil {
-		return nil, nil, ContextError(err)
+		return nil, nil, common.ContextError(err)
 	}
 
 	clientToServerCipher.XORKeyStream(fixedLengthFields, fixedLengthFields)
@@ -220,25 +222,25 @@ func readSeedMessage(
 	var magicValue, paddingLength int32
 	err = binary.Read(buffer, binary.BigEndian, &magicValue)
 	if err != nil {
-		return nil, nil, ContextError(err)
+		return nil, nil, common.ContextError(err)
 	}
 	err = binary.Read(buffer, binary.BigEndian, &paddingLength)
 	if err != nil {
-		return nil, nil, ContextError(err)
+		return nil, nil, common.ContextError(err)
 	}
 
 	if magicValue != OBFUSCATE_MAGIC_VALUE {
-		return nil, nil, ContextError(errors.New("invalid magic value"))
+		return nil, nil, common.ContextError(errors.New("invalid magic value"))
 	}
 
 	if paddingLength < 0 || paddingLength > OBFUSCATE_MAX_PADDING {
-		return nil, nil, ContextError(errors.New("invalid padding length"))
+		return nil, nil, common.ContextError(errors.New("invalid padding length"))
 	}
 
 	padding := make([]byte, paddingLength)
 	_, err = io.ReadFull(clientReader, padding)
 	if err != nil {
-		return nil, nil, ContextError(err)
+		return nil, nil, common.ContextError(err)
 	}
 
 	clientToServerCipher.XORKeyStream(padding, padding)

+ 4 - 3
psiphon/common/obfuscator_test.go → psiphon/common/obfuscator/obfuscator_test.go

@@ -17,7 +17,7 @@
  *
  */
 
-package common
+package obfuscator
 
 import (
 	"bytes"
@@ -28,12 +28,13 @@ import (
 	"testing"
 	"time"
 
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/crypto/ssh"
 )
 
 func TestObfuscator(t *testing.T) {
 
-	keyword, _ := MakeRandomStringHex(32)
+	keyword, _ := common.MakeRandomStringHex(32)
 
 	config := &ObfuscatorConfig{
 		Keyword:    keyword,
@@ -75,7 +76,7 @@ func TestObfuscator(t *testing.T) {
 
 func TestObfuscatedSSHConn(t *testing.T) {
 
-	keyword, _ := MakeRandomStringHex(32)
+	keyword, _ := common.MakeRandomStringHex(32)
 
 	serverAddress := "127.0.0.1:2222"
 

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

@@ -416,7 +416,7 @@ func (p *ClientParameters) Set(
 
 			// 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:
+			// values will not have the expected types; see:
 			// https://golang.org/pkg/encoding/json/#Unmarshal. This remarshal
 			// also results in a deep copy.
 

+ 175 - 50
psiphon/common/tactics/tactics.go

@@ -162,6 +162,7 @@ import (
 	"errors"
 	"fmt"
 	"io/ioutil"
+	"net"
 	"net/http"
 	"sort"
 	"time"
@@ -169,6 +170,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/obfuscator"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters"
 )
 
@@ -184,6 +186,7 @@ const (
 	SPEED_TEST_PADDING_MIN_SIZE        = 0
 	SPEED_TEST_PADDING_MAX_SIZE        = 256
 	TACTICS_PADDING_MAX_SIZE           = 256
+	TACTICS_OBFUSCATED_KEY_SIZE        = 32
 	SPEED_TEST_SAMPLES_PARAMETER_NAME  = "speed_test_samples"
 	APPLIED_TACTICS_TAG_PARAMETER_NAME = "applied_tactics_tag"
 	STORED_TACTICS_TAG_PARAMETER_NAME  = "stored_tactics_tag"
@@ -229,6 +232,10 @@ type Server struct {
 	// RequestObfuscatedKey is the tactics request obfuscation key.
 	RequestObfuscatedKey []byte
 
+	// EnforceServerSide enables server-side enforcement of certain tactics
+	// parameters via Listeners.
+	EnforceServerSide bool
+
 	// DefaultTactics is the baseline tactics for all clients. It must include a
 	// TTL and Probability.
 	DefaultTactics Tactics
@@ -398,7 +405,7 @@ func GenerateKeys() (encodedRequestPublicKey, encodedRequestPrivateKey, encodedO
 		return "", "", "", common.ContextError(err)
 	}
 
-	obfuscatedKey, err := common.MakeSecureRandomBytes(common.OBFUSCATE_KEY_LENGTH)
+	obfuscatedKey, err := common.MakeSecureRandomBytes(TACTICS_OBFUSCATED_KEY_SIZE)
 	if err != nil {
 		return "", "", "", common.ContextError(err)
 	}
@@ -447,6 +454,7 @@ func NewServer(
 			server.RequestPublicKey = newServer.RequestPublicKey
 			server.RequestPrivateKey = newServer.RequestPrivateKey
 			server.RequestObfuscatedKey = newServer.RequestObfuscatedKey
+			server.EnforceServerSide = newServer.EnforceServerSide
 			server.DefaultTactics = newServer.DefaultTactics
 			server.FilteredTactics = newServer.FilteredTactics
 
@@ -475,7 +483,7 @@ func (server *Server) Validate() error {
 	} else {
 		if len(server.RequestPublicKey) != 32 ||
 			len(server.RequestPrivateKey) != 32 ||
-			len(server.RequestObfuscatedKey) != common.OBFUSCATE_KEY_LENGTH {
+			len(server.RequestObfuscatedKey) != TACTICS_OBFUSCATED_KEY_SIZE {
 			return common.ContextError(errors.New("invalid request key"))
 		}
 	}
@@ -607,6 +615,60 @@ func (server *Server) GetTacticsPayload(
 	geoIPData common.GeoIPData,
 	apiParams common.APIParameters) (*Payload, error) {
 
+	tactics, err := server.getTactics(geoIPData, apiParams)
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	if tactics == nil {
+		return nil, nil
+	}
+
+	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
+}
+
+func (server *Server) getTactics(
+	geoIPData common.GeoIPData,
+	apiParams common.APIParameters) (*Tactics, error) {
+
 	server.ReloadableFile.RLock()
 	defer server.ReloadableFile.RUnlock()
 
@@ -702,45 +764,7 @@ func (server *Server) GetTacticsPayload(
 		// 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
+	return tactics, nil
 }
 
 // TODO: refactor this copy of psiphon/server.getStringRequestParam into common?
@@ -1035,6 +1059,93 @@ func (server *Server) handleTacticsRequest(
 	server.logger.LogMetric(TACTICS_METRIC_EVENT_NAME, logFields)
 }
 
+// Listener wraps a net.Listener and applies server-side enforcement of
+// certain tactics parameters to accepted connections. Tactics filtering is
+// limited to GeoIP attributes as the client has not yet sent API paramaters.
+type Listener struct {
+	net.Listener
+	server         *Server
+	tunnelProtocol string
+	geoIPLookup    func(IPaddress string) common.GeoIPData
+}
+
+// NewListener creates a new Listener.
+func NewListener(
+	listener net.Listener,
+	server *Server,
+	tunnelProtocol string,
+	geoIPLookup func(IPaddress string) common.GeoIPData) *Listener {
+
+	return &Listener{
+		Listener:       listener,
+		server:         server,
+		tunnelProtocol: tunnelProtocol,
+		geoIPLookup:    geoIPLookup,
+	}
+}
+
+// Close calls the underlying listener's Accept, and then
+// checks if tactics for the connection set LimitTunnelProtocols.
+// If LimitTunnelProtocols is set and does not include the
+// tunnel protocol the listener is running, the accepted
+// connection is immediately closed and the underlying
+// Accept is called again.
+func (listener *Listener) Accept() (net.Conn, error) {
+	for {
+
+		conn, err := listener.Listener.Accept()
+		if err != nil {
+			// Don't modify error from net.Listener
+			return nil, err
+		}
+
+		if !listener.server.EnforceServerSide {
+			return conn, nil
+		}
+
+		geoIPData := listener.geoIPLookup(common.IPAddressFromAddr(conn.RemoteAddr()))
+
+		tactics, err := listener.server.getTactics(geoIPData, make(common.APIParameters))
+		if err != nil {
+			listener.server.logger.WithContextFields(
+				common.LogFields{"error": err}).Warning("failed to get tactics for connection")
+			// If tactics is somehow misconfigured, keep handling connections.
+			// Other error cases that follow below take the same approach.
+			return conn, nil
+		}
+
+		if tactics == nil {
+			// This server isn't configured with tactics.
+			return conn, nil
+		}
+
+		limitTunnelProtocolsParameter, ok := tactics.Parameters[parameters.LimitTunnelProtocols]
+		if !ok {
+			// The tactics for the connection don't set LimitTunnelProtocols.
+			return conn, nil
+		}
+
+		if !common.FlipWeightedCoin(tactics.Probability) {
+			// Skip tactics with the configured probability.
+			return conn, nil
+		}
+
+		limitTunnelProtocols, ok := common.GetStringSlice(limitTunnelProtocolsParameter)
+		if !ok ||
+			len(limitTunnelProtocols) == 0 ||
+			common.Contains(limitTunnelProtocols, listener.tunnelProtocol) {
+
+			// The parameter is invalid; or no limit is set; or the
+			// listener protocol is not prohibited.
+			return conn, nil
+		}
+
+		// Don't accept this connection as its tactics prohibits the
+		// listener's tunnel protocol.
+		conn.Close()
+	}
+}
+
 // 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.
@@ -1519,6 +1630,13 @@ func boxPayload(
 	nonce, peerPublicKey, privateKey, obfuscatedKey, bundlePublicKey []byte,
 	payload interface{}) ([]byte, error) {
 
+	if len(nonce) > 24 ||
+		len(peerPublicKey) != 32 ||
+		len(privateKey) != 32 {
+		return nil, common.ContextError(
+			errors.New("unexpected box key length"))
+	}
+
 	marshaledPayload, err := json.Marshal(payload)
 	if err != nil {
 		return nil, common.ContextError(err)
@@ -1528,8 +1646,8 @@ func boxPayload(
 	copy(nonceArray[:], nonce)
 
 	var peerPublicKeyArray, privateKeyArray [32]byte
-	copy(peerPublicKeyArray[:], peerPublicKey[0:32])
-	copy(privateKeyArray[:], privateKey[0:32])
+	copy(peerPublicKeyArray[:], peerPublicKey)
+	copy(privateKeyArray[:], privateKey)
 
 	box := box.Seal(nil, marshaledPayload, &nonceArray, &peerPublicKeyArray, &privateKeyArray)
 
@@ -1540,8 +1658,8 @@ func boxPayload(
 		box = bundledBox
 	}
 
-	obfuscator, err := common.NewClientObfuscator(
-		&common.ObfuscatorConfig{
+	obfuscator, err := obfuscator.NewClientObfuscator(
+		&obfuscator.ObfuscatorConfig{
 			Keyword:    string(obfuscatedKey),
 			MaxPadding: TACTICS_PADDING_MAX_SIZE})
 	if err != nil {
@@ -1562,11 +1680,18 @@ func unboxPayload(
 	nonce, peerPublicKey, privateKey, obfuscatedKey, obfuscatedBoxedPayload []byte,
 	payload interface{}) ([]byte, error) {
 
+	if len(nonce) > 24 ||
+		(peerPublicKey != nil && len(peerPublicKey) != 32) ||
+		len(privateKey) != 32 {
+		return nil, common.ContextError(
+			errors.New("unexpected box key length"))
+	}
+
 	obfuscatedReader := bytes.NewReader(obfuscatedBoxedPayload[:])
 
-	obfuscator, err := common.NewServerObfuscator(
+	obfuscator, err := obfuscator.NewServerObfuscator(
 		obfuscatedReader,
-		&common.ObfuscatorConfig{Keyword: string(obfuscatedKey)})
+		&obfuscator.ObfuscatorConfig{Keyword: string(obfuscatedKey)})
 	if err != nil {
 		return nil, common.ContextError(err)
 	}
@@ -1583,18 +1708,18 @@ func unboxPayload(
 	copy(nonceArray[:], nonce)
 
 	var peerPublicKeyArray, privateKeyArray [32]byte
-	copy(privateKeyArray[:], privateKey[0:32])
+	copy(privateKeyArray[:], privateKey)
 
 	var bundledPeerPublicKey []byte
 
 	if peerPublicKey != nil {
-		copy(peerPublicKeyArray[:], peerPublicKey[0:32])
+		copy(peerPublicKeyArray[:], peerPublicKey)
 	} else {
 		if len(boxedPayload) < 32 {
 			return nil, common.ContextError(errors.New("unexpected box size"))
 		}
 		bundledPeerPublicKey = boxedPayload[0:32]
-		copy(peerPublicKeyArray[0:32], bundledPeerPublicKey)
+		copy(peerPublicKeyArray[:], bundledPeerPublicKey)
 		boxedPayload = boxedPayload[32:]
 	}
 

+ 106 - 1
psiphon/common/tactics/tactics_test.go

@@ -50,6 +50,7 @@ func TestTactics(t *testing.T) {
       "RequestPublicKey" : "%s",
       "RequestPrivateKey" : "%s",
       "RequestObfuscatedKey" : "%s",
+      "EnforceServerSide" : true,
       "DefaultTactics" : {
         "TTL" : "1s",
         "Probability" : %0.1f,
@@ -97,6 +98,16 @@ func TestTactics(t *testing.T) {
               "ConnectionWorkerPoolSize" : %d
             }
           }
+        },
+        {
+          "Filter" : {
+            "Regions": ["R7"]
+          },
+          "Tactics" : {
+            "Parameters" : {
+              "LimitTunnelProtocols" : ["SSH"]
+            }
+          }
         }
       ]
     }
@@ -116,6 +127,10 @@ func TestTactics(t *testing.T) {
 	tacticsLimitTunnelProtocols := protocol.TunnelProtocols{"OSSH", "SSH"}
 	jsonTacticsLimitTunnelProtocols, _ := json.Marshal(tacticsLimitTunnelProtocols)
 
+	listenerProtocol := "OSSH"
+	listenerProhibitedGeoIP := func(string) common.GeoIPData { return common.GeoIPData{Country: "R7"} }
+	listenerAllowedGeoIP := func(string) common.GeoIPData { return common.GeoIPData{Country: "R8"} }
+
 	tacticsConfig := fmt.Sprintf(
 		tacticsConfigTemplate,
 		encodedRequestPublicKey,
@@ -680,8 +695,98 @@ func TestTactics(t *testing.T) {
 		t.Fatalf("HandleEndPoint unexpectedly handled request")
 	}
 
-	// TODO: test replay attack defence
+	// Test Listener
 
+	tacticsProbability = 1.0
+
+	tacticsConfig = fmt.Sprintf(
+		tacticsConfigTemplate,
+		"",
+		"",
+		"",
+		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)
+	}
+
+	listenerTestCases := []struct {
+		description      string
+		geoIPLookup      func(string) common.GeoIPData
+		expectConnection bool
+	}{
+		{
+			"connection prohibited",
+			listenerProhibitedGeoIP,
+			false,
+		},
+		{
+			"connection allowed",
+			listenerAllowedGeoIP,
+			true,
+		},
+	}
+
+	for _, testCase := range listenerTestCases {
+		t.Run(testCase.description, func(t *testing.T) {
+
+			tcpListener, err := net.Listen("tcp", ":0")
+			if err != nil {
+				t.Fatalf(" net.Listen failed: %s", err)
+			}
+
+			tacticsListener := NewListener(
+				tcpListener,
+				server,
+				listenerProtocol,
+				testCase.geoIPLookup)
+
+			clientConn, err := net.Dial("tcp", tacticsListener.Addr().String())
+			if err != nil {
+				t.Fatalf(" net.Dial failed: %s", err)
+				return
+			}
+
+			result := make(chan struct{}, 1)
+
+			go func() {
+				serverConn, err := tacticsListener.Accept()
+				if err == nil {
+					result <- *new(struct{})
+					serverConn.Close()
+				}
+			}()
+
+			timer := time.NewTimer(3 * time.Second)
+			defer timer.Stop()
+
+			select {
+			case <-result:
+				if !testCase.expectConnection {
+					t.Fatalf("unexpected accepted connection")
+				}
+			case <-timer.C:
+				if testCase.expectConnection {
+					t.Fatalf("timeout before expected accepted connection")
+				}
+			}
+
+			clientConn.Close()
+			tacticsListener.Close()
+		})
+	}
+
+	// TODO: test replay attack defence
 	// TODO: test Server.Validate with invalid tactics configurations
 }
 

+ 36 - 1
psiphon/common/utils.go

@@ -70,11 +70,30 @@ func ContainsInt(list []int, target int) bool {
 	return false
 }
 
+// GetStringSlice converts an interface{} which is
+// of type []interace{}, and with the type of each
+// element a string, to []string.
+func GetStringSlice(value interface{}) ([]string, bool) {
+	slice, ok := value.([]interface{})
+	if !ok {
+		return nil, false
+	}
+	strSlice := make([]string, len(slice))
+	for index, element := range slice {
+		str, ok := element.(string)
+		if !ok {
+			return nil, false
+		}
+		strSlice[index] = str
+	}
+	return strSlice, true
+}
+
 // FlipCoin is a helper function that randomly
 // returns true or false.
 //
 // If the underlying random number generator fails,
-// FlipCoin still returns a result.
+// FlipCoin still returns false.
 func FlipCoin() bool {
 	randomInt, _ := MakeSecureRandomInt(2)
 	return randomInt == 1
@@ -119,6 +138,22 @@ func MakeSecureRandomInt64(max int64) (int64, error) {
 	return randomInt.Int64(), nil
 }
 
+// MakeSecureRandomPerm returns a random permutation of [0,max).
+func MakeSecureRandomPerm(max int) ([]int, error) {
+	// Based on math/rand.Rand.Perm:
+	// https://github.com/golang/go/blob/release-branch.go1.9/src/math/rand/rand.go#L189
+	perm := make([]int, max)
+	for i := 1; i < max; i++ {
+		j, err := MakeSecureRandomInt(i + 1)
+		if err != nil {
+			return nil, ContextError(err)
+		}
+		perm[i] = perm[j]
+		perm[j] = i
+	}
+	return perm, nil
+}
+
 // MakeSecureRandomBytes is a helper function that wraps
 // crypto/rand.Read.
 func MakeSecureRandomBytes(length int) ([]byte, error) {

+ 48 - 0
psiphon/common/utils_test.go

@@ -21,12 +21,60 @@ package common
 
 import (
 	"bytes"
+	"encoding/json"
 	"fmt"
 	"math"
+	"reflect"
 	"testing"
 	"time"
 )
 
+func TestGetStringSlice(t *testing.T) {
+
+	originalSlice := []string{"a", "b", "c"}
+
+	j, err := json.Marshal(originalSlice)
+	if err != nil {
+		t.Errorf("json.Marshal failed: %s", err)
+	}
+
+	var value interface{}
+
+	err = json.Unmarshal(j, &value)
+	if err != nil {
+		t.Errorf("json.Unmarshal failed: %s", err)
+	}
+
+	newSlice, ok := GetStringSlice(value)
+	if !ok {
+		t.Errorf("GetStringSlice failed")
+	}
+
+	if !reflect.DeepEqual(originalSlice, newSlice) {
+		t.Errorf("unexpected GetStringSlice output")
+	}
+}
+
+func TestMakeSecureRandomPerm(t *testing.T) {
+	for n := 0; n < 1000; n++ {
+		perm, err := MakeSecureRandomPerm(n)
+		if err != nil {
+			t.Errorf("MakeSecureRandomPerm failed: %s", err)
+		}
+		if len(perm) != n {
+			t.Error("unexpected permutation size")
+		}
+		sum := 0
+		for i := 0; i < n; i++ {
+			sum += perm[i]
+		}
+		expectedSum := (n * (n - 1)) / 2
+		if sum != expectedSum {
+			t.Error("unexpected permutation")
+		}
+	}
+}
+
 func TestMakeRandomPeriod(t *testing.T) {
 	min := 1 * time.Nanosecond
 	max := 10000 * time.Nanosecond

+ 14 - 0
psiphon/config.go

@@ -117,6 +117,12 @@ type Config struct {
 	// DisableLocalHTTPProxy disables running the local HTTP proxy.
 	DisableLocalHTTPProxy bool
 
+	// NetworkLatencyMultiplier is a multiplier that is to be applied to
+	// default network event timeouts. Set this to tune performance for
+	// slow networks.
+	// When set, must be >= 1.0.
+	NetworkLatencyMultiplier float64
+
 	// TunnelProtocol indicates which protocol to use. For the default, "",
 	// all protocols are used.
 	//
@@ -220,6 +226,10 @@ type Config struct {
 	// This parameter is only applicable to library deployments.
 	NetworkIDGetter NetworkIDGetter
 
+	// DisableTactics disables tactics operations including requests, payload
+	// handling, and application of parameters.
+	DisableTactics bool
+
 	// TransformHostNames specifies whether to use hostname transformation
 	// circumvention strategies. Set to "always" to always transform, "never"
 	// to never transform, and "", the default, for the default transformation
@@ -664,6 +674,10 @@ func (config *Config) makeConfigParameters() map[string]interface{} {
 
 	applyParameters := make(map[string]interface{})
 
+	if config.NetworkLatencyMultiplier > 0.0 {
+		applyParameters[parameters.NetworkLatencyMultiplier] = config.NetworkLatencyMultiplier
+	}
+
 	if len(config.TunnelProtocols) > 0 {
 		applyParameters[parameters.LimitTunnelProtocols] = protocol.TunnelProtocols(config.TunnelProtocols)
 	} else if config.TunnelProtocol != "" {

+ 8 - 3
psiphon/controller.go

@@ -166,7 +166,12 @@ func NewController(config *Config) (controller *Controller, err error) {
 // component fails or the parent context is canceled.
 func (controller *Controller) Run(ctx context.Context) {
 
-	ReportAvailableRegions()
+	// Ensure fresh repetitive notice state for each run, so the
+	// client will always get an AvailableEgressRegions notice,
+	// an initial instance of any repetitive error notice, etc.
+	ResetRepetitiveNotices()
+
+	ReportAvailableRegions(controller.config)
 
 	runCtx, stopRunning := context.WithCancel(ctx)
 	defer stopRunning()
@@ -1177,7 +1182,8 @@ func (controller *Controller) launchEstablishing() {
 	// Any in-flight tactics request or pending retry will be
 	// canceled when establishment is stopped.
 
-	doTactics := (controller.config.NetworkIDGetter != nil)
+	doTactics := !controller.config.DisableTactics &&
+		controller.config.NetworkIDGetter != nil
 
 	if doTactics {
 
@@ -1331,7 +1337,6 @@ func (controller *Controller) getTactics(done chan struct{}) {
 			case <-controller.establishCtx.Done():
 				return
 			case <-tacticsRetryDelay.C:
-			default:
 			}
 
 			tacticsRetryDelay.Stop()

+ 19 - 16
psiphon/controller_test.go

@@ -452,6 +452,22 @@ func controllerRun(t *testing.T, runConfig *controllerRunConfig) {
 	modifyConfig["DataStoreDirectory"] = testDataDirName
 	modifyConfig["RemoteServerListDownloadFilename"] = filepath.Join(testDataDirName, "server_list_compressed")
 	modifyConfig["UpgradeDownloadFilename"] = filepath.Join(testDataDirName, "upgrade")
+
+	if runConfig.protocol != "" {
+		modifyConfig["TunnelProtocols"] = protocol.TunnelProtocols{runConfig.protocol}
+	}
+
+	// Override client retry throttle values to speed up automated
+	// tests and ensure tests complete within fixed deadlines.
+	modifyConfig["FetchRemoteServerListRetryPeriodMilliseconds"] = 250
+	modifyConfig["FetchUpgradeRetryPeriodMilliseconds"] = 250
+	modifyConfig["EstablishTunnelPausePeriodSeconds"] = 1
+
+	if runConfig.disableUntunneledUpgrade {
+		// Disable untunneled upgrade downloader to ensure tunneled case is tested
+		modifyConfig["UpgradeDownloadClientVersionHeader"] = "invalid-value"
+	}
+
 	configJSON, _ = json.Marshal(modifyConfig)
 
 	config, err := LoadConfig(configJSON)
@@ -492,31 +508,17 @@ func controllerRun(t *testing.T, runConfig *controllerRunConfig) {
 	// that the tactics request succeeds.
 	config.NetworkIDGetter = &testNetworkGetter{}
 
-	// The following config values must be applied through client parameters
-	// (setting the fields in Config directly will have no effect since the
-	// client parameters have been populated by LoadConfig).
+	// The following values can only be applied through client parameters.
+	// TODO: a successful tactics request can reset these 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 {
 		applyParameters[parameters.TransformHostNameProbability] = 1.0
 	} else {
 		applyParameters[parameters.TransformHostNameProbability] = 0.0
 	}
 
-	// Override client retry throttle values to speed up automated
-	// tests and ensure tests complete within fixed deadlines.
-	applyParameters[parameters.FetchRemoteServerListRetryPeriod] = "250ms"
-	applyParameters[parameters.FetchUpgradeRetryPeriod] = "250ms"
-	applyParameters[parameters.EstablishTunnelPausePeriod] = "250ms"
-
-	applyParameters[parameters.LimitTunnelProtocols] = protocol.TunnelProtocols{runConfig.protocol}
-
 	err = config.SetClientParameters("", true, applyParameters)
 	if err != nil {
 		t.Fatalf("SetClientParameters failed: %s", err)
@@ -580,6 +582,7 @@ func controllerRun(t *testing.T, runConfig *controllerRunConfig) {
 			case "ConnectingServer":
 
 				serverProtocol := payload["protocol"].(string)
+
 				if runConfig.protocol != "" && serverProtocol != runConfig.protocol {
 					// TODO: wrong goroutine for t.FatalNow()
 					t.Fatalf("wrong protocol selected: %s", serverProtocol)

+ 28 - 7
psiphon/dataStore.go

@@ -285,7 +285,11 @@ func StoreServerEntry(serverEntry *protocol.ServerEntry, replaceIfExists bool) e
 
 // StoreServerEntries stores a list of server entries.
 // There is an independent transaction for each entry insert/update.
-func StoreServerEntries(serverEntries []*protocol.ServerEntry, replaceIfExists bool) error {
+func StoreServerEntries(
+	config *Config,
+	serverEntries []*protocol.ServerEntry,
+	replaceIfExists bool) error {
+
 	checkInitDataStore()
 
 	for _, serverEntry := range serverEntries {
@@ -297,7 +301,7 @@ func StoreServerEntries(serverEntries []*protocol.ServerEntry, replaceIfExists b
 
 	// Since there has possibly been a significant change in the server entries,
 	// take this opportunity to update the available egress regions.
-	ReportAvailableRegions()
+	ReportAvailableRegions(config)
 
 	return nil
 }
@@ -305,7 +309,9 @@ func StoreServerEntries(serverEntries []*protocol.ServerEntry, replaceIfExists b
 // StreamingStoreServerEntries stores a list of server entries.
 // There is an independent transaction for each entry insert/update.
 func StreamingStoreServerEntries(
-	serverEntries *protocol.StreamingServerEntryDecoder, replaceIfExists bool) error {
+	config *Config,
+	serverEntries *protocol.StreamingServerEntryDecoder,
+	replaceIfExists bool) error {
 
 	checkInitDataStore()
 
@@ -333,7 +339,7 @@ func StreamingStoreServerEntries(
 
 	// Since there has possibly been a significant change in the server entries,
 	// take this opportunity to update the available egress regions.
-	ReportAvailableRegions()
+	ReportAvailableRegions(config)
 
 	return nil
 }
@@ -637,6 +643,13 @@ func (iterator *ServerEntryIterator) Reset() error {
 
 		count := CountServerEntries(iterator.config.EgressRegion, limitTunnelProtocols)
 		NoticeCandidateServers(iterator.config.EgressRegion, limitTunnelProtocols, count)
+
+		// LimitTunnelProtocols may have changed since the last ReportAvailableRegions,
+		// and now there may be no servers with the required capabilities in the
+		// selected region. ReportAvailableRegions will signal this to the client.
+		if count == 0 {
+			ReportAvailableRegions(iterator.config)
+		}
 	}
 
 	// This query implements the Psiphon server candidate selection
@@ -889,13 +902,21 @@ func CountNonImpairedProtocols(
 }
 
 // ReportAvailableRegions prints a notice with the available egress regions.
-// Note that this report ignores LimitTunnelProtocols.
-func ReportAvailableRegions() {
+func ReportAvailableRegions(config *Config) {
 	checkInitDataStore()
 
+	limitTunnelProtocols := config.clientParameters.Get().TunnelProtocols(
+		parameters.LimitTunnelProtocols)
+
 	regions := make(map[string]bool)
 	err := scanServerEntries(func(serverEntry *protocol.ServerEntry) {
-		regions[serverEntry.Region] = true
+		if len(limitTunnelProtocols) == 0 ||
+			// When ReportAvailableRegions is called only limitTunnelProtocols is known;
+			// impairedTunnelProtocols and excludeMeek may not apply.
+			len(serverEntry.GetSupportedProtocols(limitTunnelProtocols, nil, false)) > 0 {
+
+			regions[serverEntry.Region] = true
+		}
 	})
 
 	if err != nil {

+ 3 - 2
psiphon/meekConn.go

@@ -41,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/obfuscator"
 	"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/upstreamproxy"
@@ -1258,8 +1259,8 @@ func makeMeekCookie(
 	copy(encryptedCookie[32:], box)
 
 	// Obfuscate the encrypted data
-	obfuscator, err := common.NewClientObfuscator(
-		&common.ObfuscatorConfig{
+	obfuscator, err := obfuscator.NewClientObfuscator(
+		&obfuscator.ObfuscatorConfig{
 			Keyword:    meekObfuscatedKey,
 			MaxPadding: clientParameters.Get().Int(parameters.MeekCookieMaxPadding)})
 	if err != nil {

+ 1 - 1
psiphon/migrateDataStore_windows.go

@@ -88,7 +88,7 @@ func prepareMigrationEntries(config *Config) []*protocol.ServerEntry {
 func migrateEntries(config *Config, serverEntries []*protocol.ServerEntry, legacyDataStoreFilename string) {
 	checkInitDataStore()
 
-	err := StoreServerEntries(serverEntries, false)
+	err := StoreServerEntries(config, serverEntries, false)
 	if err != nil {
 		NoticeAlert("migrateEntries: StoreServerEntries failed: %s", err)
 	} else {

+ 9 - 0
psiphon/notice.go

@@ -784,6 +784,15 @@ func outputRepetitiveNotice(
 	}
 }
 
+// ResetRepetitiveNotices resets the repetitive notice state, so
+// the next instance of any notice will not be supressed.
+func ResetRepetitiveNotices() {
+	repetitiveNoticeMutex.Lock()
+	defer repetitiveNoticeMutex.Unlock()
+
+	repetitiveNoticeStates = make(map[string]*repetitiveNoticeState)
+}
+
 type noticeObject struct {
 	NoticeType string          `json:"noticeType"`
 	Data       json.RawMessage `json:"data"`

+ 2 - 0
psiphon/remoteServerList.go

@@ -94,6 +94,7 @@ func FetchCommonRemoteServerList(
 	}
 
 	err = StreamingStoreServerEntries(
+		config,
 		protocol.NewStreamingServerEntryDecoder(
 			serverListPayloadReader,
 			common.GetCurrentTimestamp(),
@@ -287,6 +288,7 @@ func FetchObfuscatedServerLists(
 		}
 
 		err = StreamingStoreServerEntries(
+			config,
 			protocol.NewStreamingServerEntryDecoder(
 				serverListPayloadReader,
 				common.GetCurrentTimestamp(),

+ 7 - 0
psiphon/server/geoip.go

@@ -209,6 +209,13 @@ func (geoIP *GeoIPService) GetSessionCache(sessionID string) GeoIPData {
 	return geoIPData.(GeoIPData)
 }
 
+// InSessionCache returns whether the session ID is present
+// in the session cache.
+func (geoIP *GeoIPService) InSessionCache(sessionID string) bool {
+	_, found := geoIP.sessionCache.Get(sessionID)
+	return found
+}
+
 // calculateDiscoveryValue derives a value from the client IP address to be
 // used as input in the server discovery algorithm. Since we do not explicitly
 // store the client IP address, we must derive the value here and store it for

+ 3 - 2
psiphon/server/meek.go

@@ -40,6 +40,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/obfuscator"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
 	utls "github.com/Psiphon-Labs/utls"
 )
@@ -909,9 +910,9 @@ func getMeekCookiePayload(support *SupportServices, cookieValue string) ([]byte,
 
 	reader := bytes.NewReader(decodedValue[:])
 
-	obfuscator, err := common.NewServerObfuscator(
+	obfuscator, err := obfuscator.NewServerObfuscator(
 		reader,
-		&common.ObfuscatorConfig{Keyword: support.Config.MeekObfuscatedKey})
+		&obfuscator.ObfuscatorConfig{Keyword: support.Config.MeekObfuscatedKey})
 	if err != nil {
 		return nil, common.ContextError(err)
 	}

+ 3 - 21
psiphon/server/net.go

@@ -53,7 +53,6 @@ package server
 import (
 	"net"
 	"net/http"
-	"time"
 
 	utls "github.com/Psiphon-Labs/utls"
 )
@@ -72,26 +71,9 @@ type HTTPSServer struct {
 // shutdown. ListenAndServeTLS also requires the TLS cert and key to be in files
 // and we avoid that here.
 //
-// Note that the http.Server.TLSConfig field is ignored and the
-// utls.Config parameter is used intead.
-//
-// tcpKeepAliveListener is used in http.ListenAndServeTLS but not exported,
-// so we use a copy from https://golang.org/src/net/http/server.go.
+// Note that the http.Server.TLSConfig field is ignored and the utls.Config
+// parameter is used intead.
 func (server *HTTPSServer) ServeTLS(listener net.Listener, config *utls.Config) error {
-	tlsListener := utls.NewListener(tcpKeepAliveListener{listener.(*net.TCPListener)}, config)
+	tlsListener := utls.NewListener(listener, config)
 	return server.Serve(tlsListener)
 }
-
-type tcpKeepAliveListener struct {
-	*net.TCPListener
-}
-
-func (ln tcpKeepAliveListener) Accept() (c net.Conn, err error) {
-	tc, err := ln.AcceptTCP()
-	if err != nil {
-		return
-	}
-	tc.SetKeepAlive(true)
-	tc.SetKeepAlivePeriod(3 * time.Minute)
-	return tc, nil
-}

+ 35 - 24
psiphon/server/server_test.go

@@ -444,6 +444,7 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 		paveTacticsConfigFile(
 			t, tacticsConfigFilename,
 			tacticsRequestPublicKey, tacticsRequestPrivateKey, tacticsRequestObfuscatedKey,
+			runConfig.tunnelProtocol,
 			propagationChannelID)
 	}
 
@@ -453,7 +454,9 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 	serverConfig["PsinetDatabaseFilename"] = psinetFilename
 	serverConfig["TrafficRulesFilename"] = trafficRulesFilename
 	serverConfig["OSLConfigFilename"] = oslConfigFilename
-	serverConfig["TacticsConfigFilename"] = tacticsConfigFilename
+	if doTactics {
+		serverConfig["TacticsConfigFilename"] = tacticsConfigFilename
+	}
 	serverConfig["LogFilename"] = filepath.Join(testDataDirName, "psiphond.log")
 	serverConfig["LogLevel"] = "debug"
 
@@ -542,15 +545,18 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 	localHTTPProxyPort := 8081
 
 	// Note: calling LoadConfig ensures the Config is fully initialized
-	clientConfigJSON := `
+	clientConfigJSON := fmt.Sprintf(`
     {
         "ClientPlatform" : "Windows",
         "ClientVersion" : "0",
         "SponsorId" : "0",
         "PropagationChannelId" : "0",
         "DisableRemoteServerListFetcher" : true,
-        "UseIndistinguishableTLS" : true
-    }`
+        "UseIndistinguishableTLS" : true,
+        "EstablishTunnelPausePeriodSeconds" : 1,
+        "ConnectionWorkerPoolSize" : %d,
+        "TunnelProtocols" : ["%s"]
+    }`, numTunnels, runConfig.tunnelProtocol)
 	clientConfig, _ := psiphon.LoadConfig([]byte(clientConfigJSON))
 
 	clientConfig.DataStoreDirectory = testDataDirName
@@ -579,30 +585,25 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 	}
 
 	if doTactics {
-		clientConfig.NetworkIDGetter = &testNetworkGetter{}
+		// Use a distinct prefix for network ID for each test run to
+		// ensure tactics from different runs don't apply; this is
+		// a workaround for the singleton datastore.
+		prefix := time.Now().String()
+		clientConfig.NetworkIDGetter = &testNetworkGetter{prefix: prefix}
 	}
 
-	// The following config values must be applied through client parameters
-	// (setting the fields in Config directly will have no effect since the
-	// client parameters have been populated by LoadConfig).
-
-	applyParameters := make(map[string]interface{})
-
-	applyParameters[parameters.ConnectionWorkerPoolSize] = numTunnels
-
-	applyParameters[parameters.EstablishTunnelPausePeriod] = "250ms"
-
-	applyParameters[parameters.LimitTunnelProtocols] = protocol.TunnelProtocols{runConfig.tunnelProtocol}
-
 	if doTactics {
 		// Configure nonfunctional values that must be overridden by tactics.
+
+		applyParameters := make(map[string]interface{})
+
 		applyParameters[parameters.TunnelConnectTimeout] = "1s"
 		applyParameters[parameters.TunnelRateLimits] = common.RateLimits{WriteBytesPerSecond: 1}
-	}
 
-	err = clientConfig.SetClientParameters("", true, applyParameters)
-	if err != nil {
-		t.Fatalf("SetClientParameters failed: %s", err)
+		err = clientConfig.SetClientParameters("", true, applyParameters)
+		if err != nil {
+			t.Fatalf("SetClientParameters failed: %s", err)
+		}
 	}
 
 	controller, err := psiphon.NewController(clientConfig)
@@ -1187,16 +1188,24 @@ func paveOSLConfigFile(t *testing.T, oslConfigFilename string) string {
 func paveTacticsConfigFile(
 	t *testing.T, tacticsConfigFilename string,
 	tacticsRequestPublicKey, tacticsRequestPrivateKey, tacticsRequestObfuscatedKey string,
+	tunnelProtocol string,
 	propagationChannelID string) {
 
+	// Setting LimitTunnelProtocols passively exercises the
+	// server-side LimitTunnelProtocols enforcement.
+
 	tacticsConfigJSONFormat := `
     {
       "RequestPublicKey" : "%s",
       "RequestPrivateKey" : "%s",
       "RequestObfuscatedKey" : "%s",
+      "EnforceServerSide" : true,
       "DefaultTactics" : {
         "TTL" : "60s",
-        "Probability" : 1.0
+        "Probability" : 1.0,
+        "Parameters" : {
+          "LimitTunnelProtocols" : ["%s"]
+        }
       },
       "FilteredTactics" : [
         {
@@ -1221,6 +1230,7 @@ func paveTacticsConfigFile(
 	tacticsConfigJSON := fmt.Sprintf(
 		tacticsConfigJSONFormat,
 		tacticsRequestPublicKey, tacticsRequestPrivateKey, tacticsRequestObfuscatedKey,
+		tunnelProtocol,
 		propagationChannelID)
 
 	err := ioutil.WriteFile(tacticsConfigFilename, []byte(tacticsConfigJSON), 0600)
@@ -1251,8 +1261,9 @@ const dummyClientVerificationPayload = `
 }`
 
 type testNetworkGetter struct {
+	prefix string
 }
 
-func (testNetworkGetter) GetNetworkID() string {
-	return "NETWORK1"
+func (t *testNetworkGetter) GetNetworkID() string {
+	return t.prefix + "NETWORK1"
 }

+ 22 - 1
psiphon/server/trafficRules.go

@@ -179,6 +179,11 @@ type RateLimits struct {
 	WriteUnthrottledBytes *int64
 	WriteBytesPerSecond   *int64
 	CloseAfterExhausted   *bool
+
+	// UnthrottleFirstTunnelOnly specifies whether any
+	// ReadUnthrottledBytes/WriteUnthrottledBytes apply
+	// only to the first tunnel in a session.
+	UnthrottleFirstTunnelOnly *bool
 }
 
 // CommonRateLimits converts a RateLimits to a common.RateLimits.
@@ -272,7 +277,10 @@ func (set *TrafficRulesSet) Validate() error {
 // For the return value TrafficRules, all pointer and slice fields are initialized,
 // so nil checks are not required. The caller must not modify the returned TrafficRules.
 func (set *TrafficRulesSet) GetTrafficRules(
-	tunnelProtocol string, geoIPData GeoIPData, state handshakeState) TrafficRules {
+	isFirstTunnelInSession bool,
+	tunnelProtocol string,
+	geoIPData GeoIPData,
+	state handshakeState) TrafficRules {
 
 	set.ReloadableFile.RLock()
 	defer set.ReloadableFile.RUnlock()
@@ -315,6 +323,10 @@ func (set *TrafficRulesSet) GetTrafficRules(
 		trafficRules.RateLimits.CloseAfterExhausted = new(bool)
 	}
 
+	if trafficRules.RateLimits.UnthrottleFirstTunnelOnly == nil {
+		trafficRules.RateLimits.UnthrottleFirstTunnelOnly = new(bool)
+	}
+
 	intPtr := func(i int) *int {
 		return &i
 	}
@@ -448,6 +460,10 @@ func (set *TrafficRulesSet) GetTrafficRules(
 			trafficRules.RateLimits.CloseAfterExhausted = filteredRules.Rules.RateLimits.CloseAfterExhausted
 		}
 
+		if filteredRules.Rules.RateLimits.UnthrottleFirstTunnelOnly != nil {
+			trafficRules.RateLimits.UnthrottleFirstTunnelOnly = filteredRules.Rules.RateLimits.UnthrottleFirstTunnelOnly
+		}
+
 		if filteredRules.Rules.DialTCPPortForwardTimeoutMilliseconds != nil {
 			trafficRules.DialTCPPortForwardTimeoutMilliseconds = filteredRules.Rules.DialTCPPortForwardTimeoutMilliseconds
 		}
@@ -483,6 +499,11 @@ func (set *TrafficRulesSet) GetTrafficRules(
 		break
 	}
 
+	if *trafficRules.RateLimits.UnthrottleFirstTunnelOnly && !isFirstTunnelInSession {
+		*trafficRules.RateLimits.ReadUnthrottledBytes = 0
+		*trafficRules.RateLimits.WriteUnthrottledBytes = 0
+	}
+
 	log.WithContextFields(LogFields{"trafficRules": trafficRules}).Debug("selected traffic rules")
 
 	return trafficRules

+ 28 - 6
psiphon/server/tunnelServer.go

@@ -38,6 +38,7 @@ import (
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/accesscontrol"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/crypto/ssh"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/obfuscator"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/osl"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/tactics"
@@ -143,6 +144,14 @@ func (server *TunnelServer) Run() error {
 			return common.ContextError(err)
 		}
 
+		tacticsListener := tactics.NewListener(
+			listener,
+			support.TacticsServer,
+			tunnelProtocol,
+			func(IPAddress string) common.GeoIPData {
+				return common.GeoIPData(support.GeoIPService.Lookup(IPAddress))
+			})
+
 		log.WithContextFields(
 			LogFields{
 				"localAddress":   localAddress,
@@ -152,7 +161,7 @@ func (server *TunnelServer) Run() error {
 		listeners = append(
 			listeners,
 			&sshListener{
-				Listener:       listener,
+				Listener:       tacticsListener,
 				localAddress:   localAddress,
 				tunnelProtocol: tunnelProtocol,
 			})
@@ -880,6 +889,7 @@ type sshClient struct {
 	throttledConn                        *common.ThrottledConn
 	geoIPData                            GeoIPData
 	sessionID                            string
+	isFirstTunnelInSession               bool
 	supportsServerRequests               bool
 	handshakeState                       handshakeState
 	udpChannel                           ssh.Channel
@@ -1030,8 +1040,8 @@ func (sshClient *sshClient) run(
 		if protocol.TunnelProtocolUsesObfuscatedSSH(sshClient.tunnelProtocol) {
 			// Note: NewObfuscatedSshConn blocks on network I/O
 			// TODO: ensure this won't block shutdown
-			conn, result.err = common.NewObfuscatedSshConn(
-				common.OBFUSCATION_CONN_MODE_SERVER,
+			conn, result.err = obfuscator.NewObfuscatedSshConn(
+				obfuscator.OBFUSCATION_CONN_MODE_SERVER,
 				conn,
 				sshClient.sshServer.support.Config.ObfuscatedSSHKey)
 			if result.err != nil {
@@ -1163,17 +1173,26 @@ func (sshClient *sshClient) passwordCallback(conn ssh.ConnMetadata, password []b
 
 	sessionID := sshPasswordPayload.SessionId
 
+	// The GeoIP session cache will be populated if there was a previous tunnel
+	// with this session ID. This will be true up to GEOIP_SESSION_CACHE_TTL, which
+	// is currently much longer than the OSL session cache, another option to use if
+	// the GeoIP session cache is retired (the GeoIP session cache currently only
+	// supports legacy use cases).
+	isFirstTunnelInSession := sshClient.sshServer.support.GeoIPService.InSessionCache(sessionID)
+
 	supportsServerRequests := common.Contains(
 		sshPasswordPayload.ClientCapabilities, protocol.CLIENT_CAPABILITY_SERVER_REQUESTS)
 
 	sshClient.Lock()
 
-	// After this point, sshClient.sessionID is read-only as it will be read
+	// After this point, these values are read-only as they are read
 	// without obtaining sshClient.Lock.
 	sshClient.sessionID = sessionID
-
+	sshClient.isFirstTunnelInSession = isFirstTunnelInSession
 	sshClient.supportsServerRequests = supportsServerRequests
+
 	geoIPData := sshClient.geoIPData
+
 	sshClient.Unlock()
 
 	// Store the GeoIP data associated with the session ID. This makes
@@ -2002,7 +2021,10 @@ func (sshClient *sshClient) setTrafficRules() {
 	defer sshClient.Unlock()
 
 	sshClient.trafficRules = sshClient.sshServer.support.TrafficRulesSet.GetTrafficRules(
-		sshClient.tunnelProtocol, sshClient.geoIPData, sshClient.handshakeState)
+		sshClient.isFirstTunnelInSession,
+		sshClient.tunnelProtocol,
+		sshClient.geoIPData,
+		sshClient.handshakeState)
 
 	if sshClient.throttledConn != nil {
 		// Any existing throttling state is reset.

+ 7 - 2
psiphon/serverApi.go

@@ -125,7 +125,9 @@ func (serverContext *ServerContext) doHandshakeRequest(
 
 	params := serverContext.getBaseAPIParameters()
 
-	doTactics := serverContext.tunnel.config.NetworkIDGetter != nil
+	doTactics := !serverContext.tunnel.config.DisableTactics &&
+		serverContext.tunnel.config.NetworkIDGetter != nil
+
 	networkID := ""
 	if doTactics {
 
@@ -228,7 +230,10 @@ func (serverContext *ServerContext) doHandshakeRequest(
 	// The reason we are storing the entire array of server entries at once rather
 	// than one at a time is that some desirable side-effects get triggered by
 	// StoreServerEntries that don't get triggered by StoreServerEntry.
-	err = StoreServerEntries(decodedServerEntries, true)
+	err = StoreServerEntries(
+		serverContext.tunnel.config,
+		decodedServerEntries,
+		true)
 	if err != nil {
 		return common.ContextError(err)
 	}

+ 3 - 2
psiphon/tunnel.go

@@ -36,6 +36,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/ssh"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/obfuscator"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/tactics"
@@ -966,8 +967,8 @@ func dialSsh(
 	// Add obfuscated SSH layer
 	var sshConn net.Conn = throttledConn
 	if useObfuscatedSsh {
-		sshConn, err = common.NewObfuscatedSshConn(
-			common.OBFUSCATION_CONN_MODE_CLIENT, throttledConn, serverEntry.SshObfuscatedKey)
+		sshConn, err = obfuscator.NewObfuscatedSshConn(
+			obfuscator.OBFUSCATION_CONN_MODE_CLIENT, throttledConn, serverEntry.SshObfuscatedKey)
 		if err != nil {
 			return nil, common.ContextError(err)
 		}