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

Merge branch 'master' into staging-client

Rod Hynes 9 лет назад
Родитель
Сommit
1f3d48d880

+ 2 - 0
.travis.yml

@@ -14,12 +14,14 @@ script:
 - go test -race -v ./common/protocol
 - go test -race -v ./transferstats
 - go test -race -v ./server
+- go test -race -v ./server/psinet
 - go test -race -v
 - go test -v -covermode=count -coverprofile=common.coverprofile ./common
 - go test -v -covermode=count -coverprofile=osl.coverprofile ./common/osl
 - go test -v -covermode=count -coverprofile=protocol.coverprofile ./common/protocol
 - go test -v -covermode=count -coverprofile=transferstats.coverprofile ./transferstats
 - go test -v -covermode=count -coverprofile=server.coverprofile ./server
+- go test -v -covermode=count -coverprofile=psinet.coverprofile ./server/psinet
 - go test -v -covermode=count -coverprofile=psiphon.coverprofile
 - $HOME/gopath/bin/gover
 - $HOME/gopath/bin/goveralls -coverprofile=gover.coverprofile -service=travis-ci -repotoken $COVERALLS_TOKEN

+ 381 - 0
Server/deploy_ad-hoc_server.sh

@@ -0,0 +1,381 @@
+#!/bin/bash
+
+# Make sure the script is run as root user
+if [[ ${EUID} -ne 0 ]]; then
+  echo "This script must be run as root"
+  exit 1
+fi
+
+usage () {
+	echo "Usage: $0 -a <install|remove> -h <host> [-p <port (default 22)>]"
+	echo " Uses Docker and SSH to build an ad-hoc server container and deploy it"
+	echo " (via the 'install' action) to a previously configured host. The process"
+	echo " can be reversed by use of the 'remove' action, which restores the host"
+	echo " to its previous configuration."
+	echo ""
+	echo " This script must be run with root privilges (for access to the docker daemon)"
+	echo " You will be prompted for SSH credentials and host-key authentication as needed"
+	echo ""
+	echo " Options:"
+	echo "  -a/--action"
+	echo "   Action. Allowed actions are: 'install', 'remove'. Mandatory"
+	echo "  -u/--user"
+	echo "   Username to connect with. Default 'root'. Optional, but user must belong to the docker group"
+	echo "  -h/--host"
+	echo "   Host (or IP Address) to connect to. Mandatory"
+	echo "  -p/--port"
+	echo "   Port to connect to. Default 22. Optional"
+	echo ""
+
+  exit 1
+}
+
+generate_temporary_credentials () {
+	echo "..Generating temporary 4096 bit RSA keypair"
+	ssh-keygen -t rsa -b 4096 -C "temp-psiphond-ad_hoc" -f psiphond-ad_hoc -N ""
+	PUBLIC_KEY=$(cat psiphond-ad_hoc.pub)
+
+	echo "${PUBLIC_KEY}" | ssh -o PreferredAuthentications=interactive,password -p $PORT $USER@$HOST cat >> $HOME/.ssh/authorized_keys
+	if [ $? -ne 0 ]; then
+		echo "...Failed"
+		return 1
+	fi
+}
+
+destroy_temporary_credentials () {
+	echo "..Removing the temporary key from the remote host"
+	PUBLIC_KEY=$(cat psiphond-ad_hoc.pub)
+  ssh -i psiphond-ad_hoc -o IdentitiesOnly=yes -p $PORT $USER@$HOST sed -i '/temp-psiphond-ad_hoc/d' $HOME/.ssh/authorized_keys
+	if [ $? -ne 0 ]; then
+		echo "...Failed"
+		return 1
+	fi
+
+	echo "..Removing the local temporary keys"
+	rm psiphond-ad_hoc psiphond-ad_hoc.pub
+	if [ $? -ne 0 ]; then
+		echo "...Failed"
+		return 1
+	fi
+}
+
+docker_build_builder () {
+	echo "..Building the docker container 'psiphond-build'"
+	docker build -f Dockerfile-binary-builder -t psiphond-builder .
+	if [ $? -ne 0 ]; then
+		echo "...Failed"
+		return 1
+	fi
+}
+
+docker_build_psiphond_binary () {
+	echo "..Building 'psiphond' binary"
+  cd .. && docker run --rm -v $PWD/.:/go/src/github.com/Psiphon-Labs/psiphon-tunnel-core psiphond-builder
+	if [ $? -ne 0 ]; then
+		echo "...Failed"
+    cd $BASE
+		return 1
+	fi
+
+  cd $BASE
+	stat psiphond > /dev/null 2>&1
+	if [ $? -ne 0 ]; then
+		echo "...'psiphond' binary file not found"
+		return 1
+	fi
+
+}
+
+docker_build_psiphond_container () {
+	echo "..Building the '${CONTAINER_TAG}' container"
+	docker build -t ${CONTAINER_TAG} .
+	if [ $? -ne 0 ]; then
+		echo "...Failed"
+		return 1
+	fi
+}
+
+save_image () {
+	echo "..Saving docker image to archive"
+	docker save ${CONTAINER_TAG} | gzip > $ARCHIVE
+	if [ $? -ne 0 ]; then
+		echo "...Failed"
+		return 1
+	fi
+
+	stat $ARCHIVE > /dev/null 2>&1
+	if [ $? -ne 0 ]; then
+		echo "...'${ARCHIVE}' not found"
+		return 1
+	fi
+}
+
+put_and_load_image () {
+	echo "..Copying '${ARCHIVE}' to '${HOST}:/tmp'"
+	scp -i psiphond-ad_hoc -o IdentitiesOnly=yes -P $PORT $ARCHIVE $USER@$HOST:/tmp/
+	if [ $? -ne 0 ]; then
+		echo "...Failed"
+		return 1
+	fi
+
+	echo "..Loading image into remote docker"
+  # Variable interpolation into the remote script doesn't always work as expected, use fixed paths here instead
+	ssh -i psiphond-ad_hoc -o IdentitiesOnly=yes -p $PORT $USER@$HOST zcat /tmp/psiphond-ad-hoc.tar.gz | docker load && rm /tmp/psiphond-ad-hoc.tar.gz
+	if [ $? -ne 0 ]; then
+		echo "...Failed"
+		return 1
+	fi
+}
+
+remove_image () {
+	echo "..Removing image from remote docker"
+	ssh -i psiphond-ad_hoc -o IdentitiesOnly=yes -p $PORT $USER@$HOST docker rmi ${CONTAINER_TAG}
+	if [ $? -ne 0 ]; then
+		echo "...Failed"
+		return 1
+	fi
+}
+
+put_systemd_dropin () {
+	echo "..Creating systemd unit drop-in"
+	cat <<- EOF > ad-hoc.conf
+	[Service]
+	Environment=DOCKER_CONTENT_TRUST=0
+
+	# Clear previous pre-start command before setting new one
+	# Execute these commands prior to starting the service
+	# "-" before the command means errors are not fatal to service startup
+	ExecStartPre=
+	ExecStartPre=-/usr/bin/docker stop %p-run
+	ExecStartPre=-/usr/bin/docker rm %p-run
+
+	# Clear previous start command before setting new one
+	ExecStart=
+	ExecStart=/usr/bin/docker run --rm $CONTAINER_PORT_STRING $CONTAINER_VOLUME_STRING $CONTAINER_ULIMIT_STRING $CONTAINER_SYSCTL_STRING --name %p-run ${CONTAINER_TAG}
+	EOF
+
+	ssh -i psiphond-ad_hoc -o IdentitiesOnly=yes -p $PORT $USER@$HOST mkdir -p /etc/systemd/system/psiphond.service.d
+	echo "..Ensuring drop-in directory is available"
+	if [ $? -ne 0 ]; then
+		echo "...Failed"
+		return 1
+	fi
+
+	scp -i psiphond-ad_hoc -o IdentitiesOnly=yes -P $PORT ad-hoc.conf $USER@$HOST:/etc/systemd/system/psiphond.service.d/
+	echo "..Copying drop-in to remote host"
+	if [ $? -ne 0 ]; then
+		echo "...Failed"
+		return 1
+	fi
+
+	rm ad-hoc.conf
+}
+
+remove_systemd_dropin () {
+	echo "..Removing systemd unit drop-in"
+	ssh -i psiphond-ad_hoc -o IdentitiesOnly=yes -p $PORT $USER@$HOST [ ! -f /etc/systemd/system/psiphond.service.d/ad-hoc.conf ] || rm /etc/systemd/system/psiphond.service.d/ad-hoc.conf
+	if [ $? -ne 0 ]; then
+		echo "...Failed"
+		return 1
+	fi
+}
+
+reload_systemd () {
+	echo "..Reloading systemd unit file cache"
+	ssh -i psiphond-ad_hoc -o IdentitiesOnly=yes -p $PORT $USER@$HOST systemctl daemon-reload
+	if [ $? -ne 0 ]; then
+		echo "...Failed"
+		return 1
+	fi
+}
+
+restart_psiphond () {
+	echo "..Restarting the 'psiphond' service"
+	ssh -i psiphond-ad_hoc -o IdentitiesOnly=yes -p $PORT $USER@$HOST systemctl restart psiphond
+	if [ $? -ne 0 ]; then
+		echo "...Failed"
+		return 1
+	fi
+}
+
+# Locate and change to the directory containing the script
+BASE=$( cd "$(dirname "$0")" ; pwd -P )
+cd $BASE
+
+# Validate that we're in a git repository and store the revision
+REV=$(git rev-parse --short HEAD)
+if [ $? -ne 0 ]; then
+	echo "Could not store git revision, aborting"
+	exit 1
+fi
+
+# Parse command line arguments
+while [[ $# -gt 1 ]]; do
+	key="$1"
+
+	case $key in
+		-a|--action)
+			ACTION="$2"
+			shift
+
+			if [ "${ACTION}" != "install" ] && [ "${ACTION}" != "remove" ]; then
+				echo "Got: '${ACTION}', Expected one of: 'install', or 'remove', aborting."
+				exit 1
+			fi
+
+			;;
+		-u|--user)
+			USER="$2"
+			shift
+			;;
+		-h|--host)
+			HOST="$2"
+			shift
+			;;
+		-p|--port)
+			PORT="$2"
+			shift
+			;;
+		*)
+			usage
+			;;
+	esac
+	shift
+done
+
+# Validate all mandatory parameters were set
+if [ -z $ACTION ]; then
+	echo "Action is a required parameter, aborting."
+  echo ""
+  usage
+fi
+if [ -z $HOST ]; then
+	echo "Host is a required parameter, aborting."
+  echo ""
+  usage
+fi
+
+# Set default values for unset optional paramters
+if [ -z $USER ]; then
+  USER=root
+fi
+if [ -z $PORT ]; then
+	PORT=22
+fi
+
+# Set up other global variables
+TIMESTAMP=$(date +'%Y-%m-%d_%H-%M')
+CONTAINER_TAG="psiphond/ad-hoc:${TIMESTAMP}"
+ARCHIVE="psiphond-ad-hoc.tar.gz"
+
+# Display choices and pause
+echo "[$(date)] Ad-Hoc psiphond deploy starting."
+echo ""
+echo "Configuration:"
+echo " - Action: ${ACTION}"
+echo " - User: ${USER}"
+echo " - Host: ${HOST}"
+echo " - Port: ${PORT}"
+echo " - Containter Tag: ${CONTAINER_TAG}"
+echo " - Archive Name: ${ARCHIVE}"
+echo ""
+echo "Pausing 5 seconds to allow for ^C prior to starting"
+sleep 5
+
+generate_temporary_credentials
+if [ $? -ne 0 ]; then
+	echo "Inability to generate temporary credentials is fatal, aborting"
+	exit 1
+fi
+
+if [ "${ACTION}" == "install" ]; then
+	docker_build_builder
+	if [ $? -ne 0 ]; then
+		echo "...Aborting"
+    destroy_temporary_credentials
+		exit 1
+	fi
+
+	docker_build_psiphond_binary
+	if [ $? -ne 0 ]; then
+		echo "...Aborting"
+    destroy_temporary_credentials
+		exit 1
+	fi
+
+	docker_build_psiphond_container
+	if [ $? -ne 0 ]; then
+		echo "...Aborting"
+    destroy_temporary_credentials
+		exit 1
+	fi
+
+	save_image
+	if [ $? -ne 0 ]; then
+		echo "...Aborting"
+    destroy_temporary_credentials
+		exit 1
+	fi
+
+	put_and_load_image
+	if [ $? -ne 0 ]; then
+		echo "...Aborting"
+    destroy_temporary_credentials
+		exit 1
+	fi
+
+	put_systemd_dropin
+	if [ $? -ne 0 ]; then
+		echo "...Aborting"
+    destroy_temporary_credentials
+		exit 1
+	fi
+
+	reload_systemd
+	if [ $? -ne 0 ]; then
+		echo "...Aborting"
+    destroy_temporary_credentials
+		exit 1
+	fi
+
+	restart_psiphond
+	if [ $? -ne 0 ]; then
+		echo "...Aborting"
+    destroy_temporary_credentials
+		exit 1
+	fi
+elif [ "${ACTION}" == "remove" ]; then
+	remove_systemd_dropin
+	if [ $? -ne 0 ]; then
+		echo "...Aborting"
+    destroy_temporary_credentials
+		exit 1
+	fi
+
+	reload_systemd
+	if [ $? -ne 0 ]; then
+		echo "...Aborting"
+    destroy_temporary_credentials
+		exit 1
+	fi
+
+	restart_psiphond
+	if [ $? -ne 0 ]; then
+		echo "...Aborting"
+    destroy_temporary_credentials
+		exit 1
+	fi
+
+	remove_image
+	if [ $? -ne 0 ]; then
+		echo "...Aborting"
+    destroy_temporary_credentials
+		exit 1
+	fi
+else
+	echo "Parameter validation passed, but action was not 'install' or 'remove', aborting"
+	exit 1
+fi
+
+destroy_temporary_credentials
+echo "[$(date)] Ad-Hoc psiphond deploy ended."

+ 5 - 7
psiphon/TCPConn.go

@@ -131,9 +131,9 @@ func interruptibleTCPDial(addr string, config *DialConfig) (*TCPConn, error) {
 		var netConn net.Conn
 		var err error
 		if config.UpstreamProxyUrl != "" {
-			netConn, err = proxiedTcpDial(addr, config, conn.dialResult)
+			netConn, err = proxiedTcpDial(addr, config)
 		} else {
-			netConn, err = tcpDial(addr, config, conn.dialResult)
+			netConn, err = tcpDial(addr, config)
 		}
 
 		// Mutex is necessary for referencing conn.isClosed and conn.Conn as
@@ -172,18 +172,16 @@ func interruptibleTCPDial(addr string, config *DialConfig) (*TCPConn, error) {
 
 // proxiedTcpDial wraps a tcpDial call in an upstreamproxy dial.
 func proxiedTcpDial(
-	addr string, config *DialConfig, dialResult chan error) (net.Conn, error) {
+	addr string, config *DialConfig) (net.Conn, error) {
 	dialer := func(network, addr string) (net.Conn, error) {
-		return tcpDial(addr, config, dialResult)
+		return tcpDial(addr, config)
 	}
 
-	dialHeaders, _ := common.UserAgentIfUnset(config.UpstreamProxyCustomHeaders)
-
 	upstreamDialer := upstreamproxy.NewProxyDialFunc(
 		&upstreamproxy.UpstreamProxyConfig{
 			ForwardDialFunc: dialer,
 			ProxyURIString:  config.UpstreamProxyUrl,
-			CustomHeaders:   dialHeaders,
+			CustomHeaders:   config.UpstreamProxyCustomHeaders,
 		})
 	netConn, err := upstreamDialer("tcp", addr)
 	if _, ok := err.(*upstreamproxy.Error); ok {

+ 129 - 69
psiphon/TCPConn_bind.go

@@ -24,12 +24,14 @@ package psiphon
 import (
 	"errors"
 	"fmt"
+	"math/rand"
 	"net"
 	"os"
 	"strconv"
 	"syscall"
-	"time"
 
+	"github.com/Psiphon-Inc/goarista/monotime"
+	"github.com/Psiphon-Inc/goselect"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
 )
 
@@ -37,19 +39,9 @@ import (
 //
 // To implement socket device binding, the lower-level syscall APIs are used.
 // The sequence of syscalls in this implementation are taken from:
-// https://code.google.com/p/go/issues/detail?id=6966
-func tcpDial(addr string, config *DialConfig, dialResult chan error) (net.Conn, error) {
-
-	// Like interruption, this timeout doesn't stop this connection goroutine,
-	// it just unblocks the calling interruptibleTCPDial.
-	if config.ConnectTimeout != 0 {
-		time.AfterFunc(config.ConnectTimeout, func() {
-			select {
-			case dialResult <- errors.New("connect timeout"):
-			default:
-			}
-		})
-	}
+// https://github.com/golang/go/issues/6966
+// (originally: https://code.google.com/p/go/issues/detail?id=6966)
+func tcpDial(addr string, config *DialConfig) (net.Conn, error) {
 
 	// Get the remote IP and port, resolving a domain name if necessary
 	host, strPort, err := net.SplitHostPort(addr)
@@ -68,70 +60,138 @@ func tcpDial(addr string, config *DialConfig, dialResult chan error) (net.Conn,
 		return nil, common.ContextError(errors.New("no IP address"))
 	}
 
-	// Select an IP at random from the list, so we're not always
-	// trying the same IP (when > 1) which may be blocked.
-	// TODO: retry all IPs until one connects? For now, this retry
-	// will happen on subsequent TCPDial calls, when a different IP
-	// is selected.
-	index, err := common.MakeSecureRandomInt(len(ipAddrs))
-	if err != nil {
-		return nil, common.ContextError(err)
-	}
+	// Iterate over a pseudorandom permutation of the destination
+	// IPs and attempt connections.
+	//
+	// Only continue retrying as long as the initial ConnectTimeout
+	// has not expired. Unlike net.Dial, we do not fractionalize the
+	// timeout, as the ConnectTimeout is generally intended to apply
+	// to a single attempt. So these serial retries are most useful
+	// in cases of immediate failure, such as "no route to host"
+	// errors when a host resolves to both IPv4 and IPv6 but IPv6
+	// addresses are unreachable.
+	// Retries at higher levels cover other cases: e.g.,
+	// Controller.remoteServerListFetcher will retry its entire
+	// operation and tcpDial will try a new permutation; or similarly,
+	// Controller.establishCandidateGenerator will retry a candidate
+	// tunnel server dials.
 
-	var ipv4 [4]byte
-	var ipv6 [16]byte
-	var domain int
-	ipAddr := ipAddrs[index]
-
-	// Get address type (IPv4 or IPv6)
-	if ipAddr != nil && ipAddr.To4() != nil {
-		copy(ipv4[:], ipAddr.To4())
-		domain = syscall.AF_INET
-	} else if ipAddr != nil && ipAddr.To16() != nil {
-		copy(ipv6[:], ipAddr.To16())
-		domain = syscall.AF_INET6
-	} else {
-		return nil, common.ContextError(fmt.Errorf("Got invalid IP address: %s", ipAddr.String()))
-	}
+	permutedIndexes := rand.Perm(len(ipAddrs))
 
-	// Create a socket and bind to device, when configured to do so
-	socketFd, err := syscall.Socket(domain, syscall.SOCK_STREAM, 0)
-	if err != nil {
-		return nil, common.ContextError(err)
+	lastErr := errors.New("unknown error")
+
+	var deadline monotime.Time
+	if config.ConnectTimeout != 0 {
+		deadline = monotime.Now().Add(config.ConnectTimeout)
 	}
 
-	if config.DeviceBinder != nil {
-		// WARNING: this potentially violates the direction to not call into
-		// external components after the Controller may have been stopped.
-		// TODO: rework DeviceBinder as an internal 'service' which can trap
-		// external calls when they should not be made?
-		err = config.DeviceBinder.BindToDevice(socketFd)
+	for iteration, index := range permutedIndexes {
+
+		if iteration > 0 && deadline != 0 && monotime.Now().After(deadline) {
+			// lastErr should be set by the previous iteration
+			break
+		}
+
+		// Get address type (IPv4 or IPv6)
+
+		var ipv4 [4]byte
+		var ipv6 [16]byte
+		var domain int
+		var sockAddr syscall.Sockaddr
+
+		ipAddr := ipAddrs[index]
+		if ipAddr != nil && ipAddr.To4() != nil {
+			copy(ipv4[:], ipAddr.To4())
+			domain = syscall.AF_INET
+		} else if ipAddr != nil && ipAddr.To16() != nil {
+			copy(ipv6[:], ipAddr.To16())
+			domain = syscall.AF_INET6
+		} else {
+			lastErr = common.ContextError(fmt.Errorf("Got invalid IP address: %s", ipAddr.String()))
+			continue
+		}
+		if domain == syscall.AF_INET {
+			sockAddr = &syscall.SockaddrInet4{Addr: ipv4, Port: port}
+		} else if domain == syscall.AF_INET6 {
+			sockAddr = &syscall.SockaddrInet6{Addr: ipv6, Port: port}
+		}
+
+		// Create a socket and bind to device, when configured to do so
+
+		socketFd, err := syscall.Socket(domain, syscall.SOCK_STREAM, 0)
+		if err != nil {
+			lastErr = common.ContextError(err)
+			continue
+		}
+
+		if config.DeviceBinder != nil {
+			// WARNING: this potentially violates the direction to not call into
+			// external components after the Controller may have been stopped.
+			// TODO: rework DeviceBinder as an internal 'service' which can trap
+			// external calls when they should not be made?
+			err = config.DeviceBinder.BindToDevice(socketFd)
+			if err != nil {
+				syscall.Close(socketFd)
+				lastErr = common.ContextError(fmt.Errorf("BindToDevice failed: %s", err))
+				continue
+			}
+		}
+
+		// Connect socket to the server's IP address
+
+		err = syscall.SetNonblock(socketFd, true)
 		if err != nil {
 			syscall.Close(socketFd)
-			return nil, common.ContextError(fmt.Errorf("BindToDevice failed: %s", err))
+			lastErr = common.ContextError(err)
+			continue
 		}
-	}
 
-	// Connect socket to the server's IP address
-	if domain == syscall.AF_INET {
-		sockAddr := syscall.SockaddrInet4{Addr: ipv4, Port: port}
-		err = syscall.Connect(socketFd, &sockAddr)
-	} else if domain == syscall.AF_INET6 {
-		sockAddr := syscall.SockaddrInet6{Addr: ipv6, Port: port}
-		err = syscall.Connect(socketFd, &sockAddr)
-	}
-	if err != nil {
-		syscall.Close(socketFd)
-		return nil, common.ContextError(err)
-	}
+		err = syscall.Connect(socketFd, sockAddr)
+		if err != nil {
+			if errno, ok := err.(syscall.Errno); !ok || errno != syscall.EINPROGRESS {
+				syscall.Close(socketFd)
+				lastErr = common.ContextError(err)
+				continue
+			}
+		}
 
-	// Convert the socket fd to a net.Conn
-	file := os.NewFile(uintptr(socketFd), "")
-	netConn, err := net.FileConn(file) // net.FileConn() dups socketFd
-	file.Close()                       // file.Close() closes socketFd
-	if err != nil {
-		return nil, common.ContextError(err)
+		fdset := &goselect.FDSet{}
+		fdset.Set(uintptr(socketFd))
+
+		timeout := config.ConnectTimeout
+		if config.ConnectTimeout == 0 {
+			timeout = -1
+		}
+
+		err = goselect.Select(socketFd+1, nil, fdset, nil, timeout)
+		if err != nil {
+			lastErr = common.ContextError(err)
+			continue
+		}
+		if !fdset.IsSet(uintptr(socketFd)) {
+			lastErr = common.ContextError(errors.New("file descriptor not set"))
+			continue
+		}
+
+		err = syscall.SetNonblock(socketFd, false)
+		if err != nil {
+			syscall.Close(socketFd)
+			lastErr = common.ContextError(err)
+			continue
+		}
+
+		// Convert the socket fd to a net.Conn
+
+		file := os.NewFile(uintptr(socketFd), "")
+		netConn, err := net.FileConn(file) // net.FileConn() dups socketFd
+		file.Close()                       // file.Close() closes socketFd
+		if err != nil {
+			lastErr = common.ContextError(err)
+			continue
+		}
+
+		return netConn, nil
 	}
 
-	return netConn, nil
+	return nil, lastErr
 }

+ 1 - 1
psiphon/TCPConn_nobind.go

@@ -29,7 +29,7 @@ import (
 )
 
 // tcpDial is the platform-specific part of interruptibleTCPDial
-func tcpDial(addr string, config *DialConfig, dialResult chan error) (net.Conn, error) {
+func tcpDial(addr string, config *DialConfig) (net.Conn, error) {
 
 	if config.DeviceBinder != nil {
 		return nil, common.ContextError(errors.New("psiphon.interruptibleTCPDial with DeviceBinder not supported"))

+ 29 - 16
psiphon/controller_test.go

@@ -448,8 +448,6 @@ func controllerRun(t *testing.T, runConfig *controllerRunConfig) {
 	json.Unmarshal(configJSON, &modifyConfig)
 	modifyConfig["DataStoreDirectory"] = testDataDirName
 	modifyConfig["RemoteServerListDownloadFilename"] = filepath.Join(testDataDirName, "server_list_compressed")
-	modifyConfig["ObfuscatedServerListDownloadDirectory"] = testDataDirName
-	modifyConfig["ObfuscatedServerListRootURL"] = "http://127.0.0.1/osl" // will fail
 	modifyConfig["UpgradeDownloadFilename"] = filepath.Join(testDataDirName, "upgrade")
 	configJSON, _ = json.Marshal(modifyConfig)
 
@@ -812,12 +810,11 @@ func (TestHostNameTransformer) TransformHostName(string) (string, bool) {
 
 func fetchAndVerifyWebsite(t *testing.T, httpProxyPort int) {
 
-	testUrl := "https://raw.githubusercontent.com/Psiphon-Labs/psiphon-tunnel-core/master/LICENSE"
+	testUrl := "https://psiphon.ca"
 	roundTripTimeout := 30 * time.Second
-	expectedResponsePrefix := "                    GNU GENERAL PUBLIC LICENSE"
-	expectedResponseSize := 35148
+	expectedResponseContains := "Psiphon"
 	checkResponse := func(responseBody string) bool {
-		return strings.HasPrefix(responseBody, expectedResponsePrefix) && len(responseBody) == expectedResponseSize
+		return strings.Contains(responseBody, expectedResponseContains)
 	}
 
 	// Test: use HTTP proxy
@@ -827,18 +824,20 @@ func fetchAndVerifyWebsite(t *testing.T, httpProxyPort int) {
 		t.Fatalf("error initializing proxied HTTP request: %s", err)
 	}
 
+	httpTransport := &http.Transport{
+		Proxy:             http.ProxyURL(proxyUrl),
+		DisableKeepAlives: true,
+	}
+
 	httpClient := &http.Client{
-		Transport: &http.Transport{
-			Proxy: http.ProxyURL(proxyUrl),
-		},
-		Timeout: roundTripTimeout,
+		Transport: httpTransport,
+		Timeout:   roundTripTimeout,
 	}
 
 	request, err := http.NewRequest("GET", testUrl, nil)
 	if err != nil {
 		t.Fatalf("error preparing proxied HTTP request: %s", err)
 	}
-	request.Close = true
 
 	response, err := httpClient.Do(request)
 	if err != nil {
@@ -860,8 +859,12 @@ func fetchAndVerifyWebsite(t *testing.T, httpProxyPort int) {
 
 	// Test: use direct URL proxy
 
+	httpTransport = &http.Transport{
+		DisableKeepAlives: true,
+	}
+
 	httpClient = &http.Client{
-		Transport: http.DefaultTransport,
+		Transport: httpTransport,
 		Timeout:   roundTripTimeout,
 	}
 
@@ -873,7 +876,6 @@ func fetchAndVerifyWebsite(t *testing.T, httpProxyPort int) {
 	if err != nil {
 		t.Fatalf("error preparing direct URL request: %s", err)
 	}
-	request.Close = true
 
 	response, err = httpClient.Do(request)
 	if err != nil {
@@ -890,11 +892,21 @@ func fetchAndVerifyWebsite(t *testing.T, httpProxyPort int) {
 		t.Fatalf("unexpected direct URL response")
 	}
 
+	// Delay before requesting from external service again
+	time.Sleep(1 * time.Second)
+
 	// Test: use tunneled URL proxy
 
-	response, err = httpClient.Get(
+	request, err = http.NewRequest(
+		"GET",
 		fmt.Sprintf("http://127.0.0.1:%d/tunneled/%s",
-			httpProxyPort, url.QueryEscape(testUrl)))
+			httpProxyPort, url.QueryEscape(testUrl)),
+		nil)
+	if err != nil {
+		t.Fatalf("error preparing tunneled URL request: %s", err)
+	}
+
+	response, err = httpClient.Do(request)
 	if err != nil {
 		t.Fatalf("error sending tunneled URL request: %s", err)
 	}
@@ -956,7 +968,8 @@ func initDisruptor() {
 				defer localConn.Close()
 				remoteConn, err := net.Dial("tcp", localConn.Req.Target)
 				if err != nil {
-					fmt.Printf("disruptor proxy dial error: %s\n", err)
+					// TODO: log "err" without logging server IPs
+					fmt.Printf("disruptor proxy dial error\n")
 					return
 				}
 				defer remoteConn.Close()

+ 4 - 1
psiphon/remoteServerList_test.go

@@ -74,6 +74,8 @@ func TestObfuscatedRemoteServerLists(t *testing.T) {
 			EnableSSHAPIRequests: true,
 			WebServerPort:        8001,
 			TunnelProtocolPorts:  map[string]int{"OSSH": 4001},
+			LogFilename:          "psiphond.log",
+			LogLevel:             "debug",
 		})
 	if err != nil {
 		t.Fatalf("error generating server config: %s", err)
@@ -371,7 +373,8 @@ func TestObfuscatedRemoteServerLists(t *testing.T) {
 			case "RemoteServerListResourceDownloadedBytes":
 				// TODO: check for resumed download for each URL
 				//url := payload["url"].(string)
-				printNotice = true
+				//printNotice = true
+				printNotice = false
 			case "RemoteServerListResourceDownloaded":
 				printNotice = true
 			}

+ 5 - 5
psiphon/server/api.go

@@ -22,6 +22,7 @@ package server
 import (
 	"crypto/subtle"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"net"
 	"regexp"
@@ -97,9 +98,8 @@ func dispatchAPIRequestHandler(
 	// terminating in the case of a bug.
 	defer func() {
 		if e := recover(); e != nil {
-			reterr = common.ContextError(
-				fmt.Errorf(
-					"request handler panic: %s: %s", e, debug.Stack()))
+			log.LogPanicRecover(e, debug.Stack())
+			reterr = common.ContextError(errors.New("request handler panic"))
 		}
 	}()
 
@@ -509,6 +509,7 @@ var baseRequestParams = []requestParamSpec{
 	requestParamSpec{"sponsor_id", isHexDigits, 0},
 	requestParamSpec{"client_version", isIntString, 0},
 	requestParamSpec{"client_platform", isClientPlatform, 0},
+	requestParamSpec{"client_build_rev", isHexDigits, requestParamOptional},
 	requestParamSpec{"relay_protocol", isRelayProtocol, 0},
 	requestParamSpec{"tunnel_whole_device", isBooleanFlag, requestParamOptional},
 	requestParamSpec{"device_region", isRegionCode, requestParamOptional},
@@ -519,6 +520,7 @@ var baseRequestParams = []requestParamSpec{
 	requestParamSpec{"meek_sni_server_name", isDomain, requestParamOptional},
 	requestParamSpec{"meek_host_header", isHostHeader, requestParamOptional},
 	requestParamSpec{"meek_transformed_host_name", isBooleanFlag, requestParamOptional},
+	requestParamSpec{"user_agent", isAnyString, requestParamOptional},
 	requestParamSpec{"server_entry_region", isRegionCode, requestParamOptional},
 	requestParamSpec{"server_entry_source", isServerEntrySource, requestParamOptional},
 	requestParamSpec{"server_entry_timestamp", isISO8601Date, requestParamOptional},
@@ -620,8 +622,6 @@ func getRequestLogFields(
 	logFields := make(LogFields)
 
 	logFields["event_name"] = eventName
-	logFields["host_id"] = support.Config.HostID
-	logFields["build_rev"] = common.GetBuildInfo().BuildRev
 
 	// In psi_web, the space replacement was done to accommodate space
 	// delimited logging, which is no longer required; we retain the

+ 7 - 1
psiphon/server/config.go

@@ -339,6 +339,7 @@ func validateNetworkAddress(address string, requireIPaddress bool) error {
 // a generated server config.
 type GenerateConfigParams struct {
 	LogFilename          string
+	LogLevel             string
 	ServerIPAddress      string
 	WebServerPort        int
 	EnableSSHAPIRequests bool
@@ -486,8 +487,13 @@ func GenerateConfig(params *GenerateConfigParams) ([]byte, []byte, []byte, error
 	// Note: this config is intended for either testing or as an illustrative
 	// example or template and is not intended for production deployment.
 
+	logLevel := params.LogLevel
+	if logLevel == "" {
+		logLevel = "info"
+	}
+
 	config := &Config{
-		LogLevel:                       "info",
+		LogLevel:                       logLevel,
 		LogFilename:                    params.LogFilename,
 		GeoIPDatabaseFilenames:         nil,
 		HostID:                         "example-host-id",

+ 81 - 35
psiphon/server/log.go

@@ -26,6 +26,7 @@ import (
 	"io/ioutil"
 	go_log "log"
 	"os"
+	"sync"
 
 	"github.com/Psiphon-Inc/logrus"
 	"github.com/Psiphon-Inc/rotate-safe-writer"
@@ -43,39 +44,72 @@ type ContextLogger struct {
 type LogFields logrus.Fields
 
 // WithContext adds a "context" field containing the caller's
-// function name and source file line number. Use this function
-// when the log has no fields.
+// function name and source file line number; and "host_id" and
+// "build_rev" fields identifying this server and build.
+// Use this function when the log has no fields.
 func (logger *ContextLogger) WithContext() *logrus.Entry {
-	return log.WithFields(
+	return logger.WithFields(
 		logrus.Fields{
-			"context": common.GetParentContext(),
+			"context":   common.GetParentContext(),
+			"host_id":   logHostID,
+			"build_rev": logBuildRev,
 		})
 }
 
-// WithContextFields adds a "context" field containing the caller's
-// function name and source file line number. Use this function
-// when the log has fields. Note that any existing "context" field
-// will be renamed to "field.context".
-func (logger *ContextLogger) WithContextFields(fields LogFields) *logrus.Entry {
-	_, ok := fields["context"]
-	if ok {
+func renameLogFields(fields LogFields) {
+	if _, ok := fields["context"]; ok {
 		fields["fields.context"] = fields["context"]
 	}
+	if _, ok := fields["host_id"]; ok {
+		fields["fields.host_id"] = fields["host_id"]
+	}
+	if _, ok := fields["build_rev"]; ok {
+		fields["fields.build_rev"] = fields["build_rev"]
+	}
+}
+
+// WithContextFields adds a "context" field containing the caller's
+// function name and source file line number; and "host_id" and
+// "build_rev" fields identifying this server and build.
+// Use this function when the log has fields.
+// Note that any existing "context"/"host_id"/"build_rev" field will
+// be renamed to "field.<name>".
+func (logger *ContextLogger) WithContextFields(fields LogFields) *logrus.Entry {
+	renameLogFields(fields)
 	fields["context"] = common.GetParentContext()
-	return log.WithFields(logrus.Fields(fields))
+	fields["host_id"] = logHostID
+	fields["build_rev"] = logBuildRev
+	return logger.WithFields(logrus.Fields(fields))
 }
 
 // LogRawFieldsWithTimestamp directly logs the supplied fields adding only
-// an additional "timestamp" field. The stock "msg" and "level" fields are
+// an additional "timestamp" field; and "host_id" and "build_rev" fields
+// identifying this server and build. The stock "msg" and "level" fields are
 // omitted. This log is emitted at the Error level. This function exists to
 // support API logs which have neither a natural message nor severity; and
 // omitting these values here makes it easier to ship these logs to existing
 // API log consumers.
+// Note that any existing "context"/"host_id"/"build_rev" field will
+// be renamed to "field.<name>".
 func (logger *ContextLogger) LogRawFieldsWithTimestamp(fields LogFields) {
+	renameLogFields(fields)
+	fields["host_id"] = logHostID
+	fields["build_rev"] = logBuildRev
 	logger.WithFields(logrus.Fields(fields)).Error(
 		customJSONFormatterLogRawFieldsWithTimestamp)
 }
 
+// LogPanicRecover calls LogRawFieldsWithTimestamp with standard fields
+// for logging recovered panics.
+func (logger *ContextLogger) LogPanicRecover(recoverValue interface{}, stack []byte) {
+	log.LogRawFieldsWithTimestamp(
+		LogFields{
+			"event_name":    "panic",
+			"recover_value": recoverValue,
+			"stack":         string(stack),
+		})
+}
+
 // NewLogWriter returns an io.PipeWriter that can be used to write
 // to the global logger. Caller must Close() the writer.
 func NewLogWriter() *io.PipeWriter {
@@ -138,39 +172,51 @@ func (f *CustomJSONFormatter) Format(entry *logrus.Entry) ([]byte, error) {
 }
 
 var log *ContextLogger
+var logHostID, logBuildRev string
+var initLogging sync.Once
 
 // InitLogging configures a logger according to the specified
 // config params. If not called, the default logger set by the
 // package init() is used.
-// Concurrenty note: should only be called from the main
-// goroutine.
-func InitLogging(config *Config) error {
+// Concurrency notes: this should only be called from the main
+// goroutine; InitLogging only has effect on the first call, as
+// the logging facilities it initializes may be in use by other
+// goroutines after that point.
+func InitLogging(config *Config) (retErr error) {
 
-	level, err := logrus.ParseLevel(config.LogLevel)
-	if err != nil {
-		return common.ContextError(err)
-	}
+	initLogging.Do(func() {
 
-	var logWriter io.Writer
+		logHostID = config.HostID
+		logBuildRev = common.GetBuildInfo().BuildRev
 
-	if config.LogFilename != "" {
-		logWriter, err = rotate.NewRotatableFileWriter(config.LogFilename, 0666)
+		level, err := logrus.ParseLevel(config.LogLevel)
 		if err != nil {
-			return common.ContextError(err)
+			retErr = common.ContextError(err)
+			return
 		}
-	} else {
-		logWriter = os.Stderr
-	}
 
-	log = &ContextLogger{
-		&logrus.Logger{
-			Out:       logWriter,
-			Formatter: &CustomJSONFormatter{},
-			Level:     level,
-		},
-	}
+		var logWriter io.Writer
+
+		if config.LogFilename != "" {
+			logWriter, err = rotate.NewRotatableFileWriter(config.LogFilename, 0666)
+			if err != nil {
+				retErr = common.ContextError(err)
+				return
+			}
+		} else {
+			logWriter = os.Stderr
+		}
+
+		log = &ContextLogger{
+			&logrus.Logger{
+				Out:       logWriter,
+				Formatter: &CustomJSONFormatter{},
+				Level:     level,
+			},
+		}
+	})
 
-	return nil
+	return retErr
 }
 
 func init() {

+ 24 - 9
psiphon/server/psinet/psinet.go

@@ -274,7 +274,8 @@ func (db *Database) GetHttpsRequestRegexes(sponsorID string) []map[string]string
 }
 
 // DiscoverServers selects new encoded server entries to be "discovered" by
-// the client, using the discoveryValue as the input into the discovery algorithm.
+// the client, using the discoveryValue -- a function of the client's IP
+// address -- as the input into the discovery algorithm.
 // The server list (db.Servers) loaded from JSON is stored as an array instead of
 // a map to ensure servers are discovered deterministically. Each iteration over a
 // map in go is seeded with a random value which causes non-deterministic ordering.
@@ -307,7 +308,9 @@ func (db *Database) DiscoverServers(discoveryValue int) []string {
 			}
 		}
 	}
-	servers = selectServers(candidateServers, discoveryValue)
+
+	timeInSeconds := int(discoveryDate.Unix())
+	servers = selectServers(candidateServers, timeInSeconds, discoveryValue)
 
 	encodedServerEntries := make([]string, 0)
 
@@ -333,15 +336,14 @@ func (db *Database) DiscoverServers(discoveryValue int) []string {
 // both aspects determine which server is selected. IP address is given the
 // priority: if there are only a couple of servers, for example, IP address alone
 // determines the outcome.
-func selectServers(servers []Server, discoveryValue int) []Server {
+func selectServers(servers []Server, timeInSeconds, discoveryValue int) []Server {
 	TIME_GRANULARITY := 3600
 
 	if len(servers) == 0 {
 		return nil
 	}
 
-	// Current time truncated to an hour
-	timeInSeconds := int(time.Now().Unix())
+	// Time truncated to an hour
 	timeStrategyValue := timeInSeconds / TIME_GRANULARITY
 
 	// Divide servers into buckets. The bucket count is chosen such that the number
@@ -350,6 +352,7 @@ func selectServers(servers []Server, discoveryValue int) []Server {
 
 	// NOTE: this code assumes that the range of possible timeStrategyValues
 	// and discoveryValues are sufficient to index to all bucket items.
+
 	bucketCount := calculateBucketCount(len(servers))
 
 	buckets := bucketizeServerList(servers, bucketCount)
@@ -378,14 +381,26 @@ func calculateBucketCount(length int) int {
 	return int(math.Ceil(math.Sqrt(float64(length))))
 }
 
-// Create bucketCount nearly equal sized buckets.
+// bucketizeServerList creates nearly equal sized slices of the input list.
 func bucketizeServerList(servers []Server, bucketCount int) [][]Server {
 
+	// This code creates the same partitions as legacy servers:
+	// https://bitbucket.org/psiphon/psiphon-circumvention-system/src/03bc1a7e51e7c85a816e370bb3a6c755fd9c6fee/Automation/psi_ops_discovery.py
+	//
+	// Both use the same algorithm from:
+	// http://stackoverflow.com/questions/2659900/python-slicing-a-list-into-n-nearly-equal-length-partitions
+
+	// TODO: this partition is constant for fixed Database content, so it could
+	// be done once and cached in the Database ReloadableFile reloadAction.
+
 	buckets := make([][]Server, bucketCount)
 
-	for index, server := range servers {
-		bucketIndex := index % bucketCount
-		buckets[bucketIndex] = append(buckets[bucketIndex], server)
+	division := float64(len(servers)) / float64(bucketCount)
+
+	for i := 0; i < bucketCount; i++ {
+		start := int((division * float64(i)) + 0.5)
+		end := int((division * (float64(i) + 1)) + 0.5)
+		buckets[i] = servers[start:end]
 	}
 
 	return buckets

+ 135 - 0
psiphon/server/psinet/psinet_test.go

@@ -0,0 +1,135 @@
+/*
+ * Copyright (c) 2017, Psiphon Inc.
+ * All rights reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+package psinet
+
+import (
+	"fmt"
+	"testing"
+	"time"
+)
+
+func TestDiscoveryBuckets(t *testing.T) {
+
+	checkBuckets := func(buckets [][]Server, expectedIDs [][]int) {
+		if len(buckets) != len(expectedIDs) {
+			t.Errorf(
+				"unexpected bucket count: got %d expected %d",
+				len(buckets), len(expectedIDs))
+			return
+		}
+		for i := 0; i < len(buckets); i++ {
+			if len(buckets[i]) != len(expectedIDs[i]) {
+				t.Errorf(
+					"unexpected bucket %d size: got %d expected %d",
+					i, len(buckets[i]), len(expectedIDs[i]))
+				return
+			}
+			for j := 0; j < len(buckets[i]); j++ {
+				expectedID := fmt.Sprintf("%d", expectedIDs[i][j])
+				if buckets[i][j].Id != expectedID {
+					t.Errorf(
+						"unexpected bucket %d item %d: got %s expected %s",
+						i, j, buckets[i][j].Id, expectedID)
+					return
+				}
+			}
+		}
+	}
+
+	// Partition test cases from:
+	// http://stackoverflow.com/questions/2659900/python-slicing-a-list-into-n-nearly-equal-length-partitions
+
+	servers := make([]Server, 0)
+	for i := 0; i < 105; i++ {
+		servers = append(servers, Server{Id: fmt.Sprintf("%d", i)})
+	}
+
+	t.Run("5 servers, 5 buckets", func(t *testing.T) {
+		checkBuckets(
+			bucketizeServerList(servers[0:5], 5),
+			[][]int{{0}, {1}, {2}, {3}, {4}})
+	})
+
+	t.Run("5 servers, 2 buckets", func(t *testing.T) {
+		checkBuckets(
+			bucketizeServerList(servers[0:5], 2),
+			[][]int{{0, 1, 2}, {3, 4}})
+	})
+
+	t.Run("5 servers, 3 buckets", func(t *testing.T) {
+		checkBuckets(
+			bucketizeServerList(servers[0:5], 3),
+			[][]int{{0, 1}, {2}, {3, 4}})
+	})
+
+	t.Run("105 servers, 10 buckets", func(t *testing.T) {
+		checkBuckets(
+			bucketizeServerList(servers, 10),
+			[][]int{
+				{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
+				{11, 12, 13, 14, 15, 16, 17, 18, 19, 20},
+				{21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31},
+				{32, 33, 34, 35, 36, 37, 38, 39, 40, 41},
+				{42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52},
+				{53, 54, 55, 56, 57, 58, 59, 60, 61, 62},
+				{63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73},
+				{74, 75, 76, 77, 78, 79, 80, 81, 82, 83},
+				{84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94},
+				{95, 96, 97, 98, 99, 100, 101, 102, 103, 104},
+			})
+	})
+
+	t.Run("repeatedly discover with fixed IP address", func(t *testing.T) {
+
+		// For a IP address values, only one bucket should be used; with enough
+		// iterations, all and only the items in a single bucket should be discovered.
+
+		discoveredServers := make(map[string]bool)
+
+		// discoveryValue is derived from the client's IP address and indexes the bucket;
+		// a value of 0 always maps to the first bucket.
+		discoveryValue := 0
+
+		for i := 0; i < 1000; i++ {
+			for _, server := range selectServers(servers, i*int(time.Hour/time.Second), discoveryValue) {
+				discoveredServers[server.Id] = true
+			}
+		}
+
+		bucketCount := calculateBucketCount(len(servers))
+
+		buckets := bucketizeServerList(servers, bucketCount)
+
+		if len(buckets[0]) != len(discoveredServers) {
+			t.Errorf(
+				"unexpected discovered server count: got %d expected %d",
+				len(discoveredServers), len(buckets[0]))
+			return
+		}
+
+		for _, bucketServer := range buckets[0] {
+			if _, ok := discoveredServers[bucketServer.Id]; !ok {
+				t.Errorf("unexpected missing discovery server: %s", bucketServer.Id)
+				return
+			}
+		}
+	})
+
+}

+ 10 - 2
psiphon/server/server_test.go

@@ -302,7 +302,8 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 	serverConfig["PsinetDatabaseFilename"] = psinetFilename
 	serverConfig["TrafficRulesFilename"] = trafficRulesFilename
 	serverConfig["OSLConfigFilename"] = oslConfigFilename
-	serverConfig["LogLevel"] = "error"
+	serverConfig["LogFilename"] = "psiphond.log"
+	serverConfig["LogLevel"] = "debug"
 
 	serverConfigJSON, _ = json.Marshal(serverConfig)
 
@@ -715,7 +716,12 @@ func makeTunneledNTPRequestAttempt(
 
 	// Tunneled NTP request
 
-	go localUDPProxy(addrs[0][len(addrs[0])-4:], 123, nil)
+	waitGroup = new(sync.WaitGroup)
+	waitGroup.Add(1)
+	go localUDPProxy(
+		addrs[0][len(addrs[0])-4:],
+		123,
+		waitGroup)
 	// TODO: properly synchronize with local UDP proxy startup
 	time.Sleep(1 * time.Second)
 
@@ -766,6 +772,8 @@ func makeTunneledNTPRequestAttempt(
 		return fmt.Errorf("Unexpected NTP time: %s; local time: %s", ntpNow, now)
 	}
 
+	waitGroup.Wait()
+
 	return nil
 }
 

+ 0 - 2
psiphon/server/services.go

@@ -283,8 +283,6 @@ func logServerLoad(server *TunnelServer) {
 	runtime.ReadMemStats(&memStats)
 	fields := LogFields{
 		"event_name":    "server_load",
-		"build_rev":     common.GetBuildInfo().BuildRev,
-		"host_id":       server.sshServer.support.Config.HostID,
 		"num_goroutine": runtime.NumGoroutine(),
 		"mem_stats": map[string]interface{}{
 			"alloc":           memStats.Alloc,

+ 12 - 2
psiphon/server/tunnelServer.go

@@ -98,6 +98,9 @@ func NewTunnelServer(
 // forward tracks its bytes transferred. Overall per-client stats for connection duration,
 // GeoIP, number of port forwards, and bytes transferred are tracked and logged when the
 // client shuts down.
+//
+// Note: client handler goroutines may still be shutting down after Run() returns. See
+// comment in sshClient.stop(). TODO: fully synchronized shutdown.
 func (server *TunnelServer) Run() error {
 
 	type sshListener struct {
@@ -1237,10 +1240,14 @@ func (sshClient *sshClient) idleUDPPortForwardTimeout() time.Duration {
 const (
 	portForwardTypeTCP = iota
 	portForwardTypeUDP
+	portForwardTypeTransparentDNS
 )
 
 func (sshClient *sshClient) isPortForwardPermitted(
-	portForwardType int, remoteIP net.IP, port int) bool {
+	portForwardType int,
+	isTransparentDNSForwarding bool,
+	remoteIP net.IP,
+	port int) bool {
 
 	sshClient.Lock()
 	defer sshClient.Unlock()
@@ -1251,7 +1258,9 @@ func (sshClient *sshClient) isPortForwardPermitted(
 
 	// Disallow connection to loopback. This is a failsafe. The server
 	// should be run on a host with correctly configured firewall rules.
-	if remoteIP.IsLoopback() {
+	// And exception is made in the case of tranparent DNS forwarding,
+	// where the remoteIP has been rewritten.
+	if !isTransparentDNSForwarding && remoteIP.IsLoopback() {
 		return false
 	}
 
@@ -1423,6 +1432,7 @@ func (sshClient *sshClient) handleTCPChannel(
 	if !isWebServerPortForward &&
 		!sshClient.isPortForwardPermitted(
 			portForwardTypeTCP,
+			false,
 			lookupResult.IP,
 			portToConnect) {
 

+ 1 - 1
psiphon/server/udp.go

@@ -163,7 +163,7 @@ func (mux *udpPortForwardMultiplexer) run() {
 			}
 
 			if !mux.sshClient.isPortForwardPermitted(
-				portForwardTypeUDP, dialIP, int(message.remotePort)) {
+				portForwardTypeUDP, message.forwardDNS, dialIP, int(message.remotePort)) {
 				// The udpgw protocol has no error response, so
 				// we just discard the message and read another.
 				continue

+ 1 - 0
psiphon/serverApi.go

@@ -816,6 +816,7 @@ func (serverContext *ServerContext) getBaseParams() requestJSONObject {
 	// TODO: client_tunnel_core_version?
 	params["relay_protocol"] = tunnel.protocol
 	params["client_platform"] = tunnel.config.ClientPlatform
+	params["client_build_rev"] = common.GetBuildInfo().BuildRev
 	params["tunnel_whole_device"] = strconv.Itoa(tunnel.config.TunnelWholeDevice)
 
 	// The following parameters may be blank and must

+ 2 - 2
psiphon/tunnel.go

@@ -600,8 +600,6 @@ func dialSsh(
 
 	default:
 		useObfuscatedSsh = true
-		dialHeaders, selectedUserAgent = common.UserAgentIfUnset(config.UpstreamProxyCustomHeaders)
-
 		meekConfig, err = initMeekConfig(config, serverEntry, selectedProtocol, sessionId)
 		if err != nil {
 			return nil, common.ContextError(err)
@@ -627,6 +625,8 @@ func dialSsh(
 		resolvedIPAddress.Store(IPAddress)
 	}
 
+	dialHeaders, selectedUserAgent = common.UserAgentIfUnset(config.UpstreamProxyCustomHeaders)
+
 	// Create the base transport: meek or direct connection
 	dialConfig := &DialConfig{
 		UpstreamProxyUrl:              config.UpstreamProxyUrl,